Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2044,7 +2044,8 @@
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets"
}
},
"noJobIdFound": "No job ID found for this asset"
},
"actionbar": {
"dockToTop": "Dock to top",
Expand Down
161 changes: 73 additions & 88 deletions src/platform/assets/composables/useMediaAssetActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { inject } from 'vue'

import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'

import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
Expand All @@ -19,6 +22,7 @@ export function useMediaAssetActions() {
const toast = useToast()
const dialogStore = useDialogStore()
const mediaContext = inject(MediaAssetKey, null)
const { copyToClipboard } = useCopyToClipboard()

const downloadAsset = () => {
const asset = mediaContext?.asset.value
Expand All @@ -33,10 +37,7 @@ export function useMediaAssetActions() {
if (isCloud && asset.src) {
downloadUrl = asset.src
} else {
const assetType = asset.tags?.[0] || 'output'
downloadUrl = api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
)
downloadUrl = getAssetUrl(asset)
}

downloadFile(downloadUrl, filename)
Expand Down Expand Up @@ -74,10 +75,7 @@ export function useMediaAssetActions() {
if (isCloud && asset.preview_url) {
downloadUrl = asset.preview_url
} else {
const assetType = asset.tags?.[0] || 'output'
downloadUrl = api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
)
downloadUrl = getAssetUrl(asset)
}
downloadFile(downloadUrl, filename)
})
Expand All @@ -101,13 +99,38 @@ export function useMediaAssetActions() {
}
}

/**
* Internal helper to perform the API deletion for a single asset
* Handles both output assets (via history API) and input assets (via asset service)
* @throws Error if deletion fails or is not allowed
*/
const deleteAssetApi = async (
asset: AssetItem,
assetType: string
): Promise<void> => {
if (assetType === 'output') {
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
await api.deleteItem('history', promptId)
} else {
// Input assets can only be deleted in cloud environment
if (!isCloud) {
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
}
await assetService.deleteAsset(asset.id)
}
}

/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
* @returns true if the asset was deleted, false otherwise
*/
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
const assetType = asset.tags?.[0] || 'output'
const assetType = getAssetType(asset)

return new Promise((resolve) => {
dialogStore.showDialog({
Expand All @@ -134,61 +157,32 @@ export function useMediaAssetActions() {
const assetsStore = useAssetsStore()

try {
if (assetType === 'output') {
// For output files, delete from history
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
// Perform the deletion
await deleteAssetApi(asset, assetType)

await api.deleteItem('history', promptId)

// Update history assets in store after deletion
// Update the appropriate store based on asset type
if (assetType === 'output') {
await assetsStore.updateHistory()

toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
} else {
// For input files, only allow deletion in cloud environment
if (!isCloud) {
toast.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('mediaAsset.deletingImportedFilesCloudOnly'),
life: 3000
})
return false
}

// In cloud environment, use the assets API to delete
await assetService.deleteAsset(asset.id)

// Update input assets in store after deletion
await assetsStore.updateInputs()

toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
}

toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
} catch (error) {
console.error('Failed to delete asset:', error)
const errorMessage = error instanceof Error ? error.message : ''
const isCloudWarning = errorMessage.includes('Cloud')

toast.add({
severity: 'error',
summary: t('g.error'),
detail:
error instanceof Error
? error.message
: t('mediaAsset.failedToDeleteAsset'),
severity: isCloudWarning ? 'warn' : 'error',
summary: isCloudWarning ? t('g.warning') : t('g.error'),
detail: errorMessage || t('mediaAsset.failedToDeleteAsset'),
life: 3000
})
return false
Expand All @@ -203,36 +197,21 @@ export function useMediaAssetActions() {
const asset = mediaContext?.asset.value
if (!asset) return

// Get promptId from metadata instead of parsing the ID string
// Try asset.id first (OSS), then fall back to metadata (Cloud)
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId
const promptId = asset.id || metadata?.promptId

if (!promptId) {
toast.add({
severity: 'warn',
summary: t('g.warning'),
detail: 'No job ID found for this asset',
detail: t('mediaAsset.noJobIdFound'),
life: 2000
})
return
}

try {
await navigator.clipboard.writeText(promptId)
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
life: 2000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
await copyToClipboard(promptId)
}

const addWorkflow = (assetId: string) => {
Expand Down Expand Up @@ -273,26 +252,32 @@ export function useMediaAssetActions() {
itemList: assets.map((asset) => asset.name),
onConfirm: async () => {
try {
// Delete all assets
// Delete all assets using the shared helper
// Silently skip assets that can't be deleted (e.g., input assets in non-cloud)
await Promise.all(
assets.map(async (asset) => {
const assetType = asset.tags?.[0] || 'output'
if (assetType === 'output') {
const promptId =
asset.id ||
getOutputAssetMetadata(asset.user_metadata)?.promptId
if (promptId) {
await api.deleteItem('history', promptId)
}
} else if (isCloud) {
await assetService.deleteAsset(asset.id)
const assetType = getAssetType(asset)
try {
await deleteAssetApi(asset, assetType)
} catch (error) {
// Log but don't fail the entire batch for individual errors
console.warn(`Failed to delete asset ${asset.name}:`, error)
}
})
)

// Update stores after deletions
await assetsStore.updateHistory()
if (assets.some((a) => a.tags?.[0] === 'input')) {
const hasOutputAssets = assets.some(
(a) => getAssetType(a) === 'output'
)
const hasInputAssets = assets.some(
(a) => getAssetType(a) === 'input'
)

if (hasOutputAssets) {
await assetsStore.updateHistory()
}
if (hasInputAssets) {
await assetsStore.updateInputs()
}

Expand Down
24 changes: 24 additions & 0 deletions src/platform/assets/utils/assetTypeUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Utilities for working with asset types
*/

import type { AssetItem } from '../schemas/assetSchema'

/**
* Extract asset type from an asset's tags array
* Falls back to a default type if tags are not present
*
* @param asset The asset to extract type from
* @param defaultType Default type to use if tags are empty (default: 'output')
* @returns The asset type ('input', 'output', 'temp', etc.)
*
* @example
* getAssetType(asset) // Returns 'output' or first tag
* getAssetType(asset, 'input') // Returns 'input' if no tags
*/
export function getAssetType(
asset: AssetItem,
defaultType: 'input' | 'output' = 'output'
): string {
return asset.tags?.[0] || defaultType
}
29 changes: 29 additions & 0 deletions src/platform/assets/utils/assetUrlUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Utilities for constructing asset URLs
*/

import { api } from '@/scripts/api'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetType } from './assetTypeUtil'

/**
* Get the download/view URL for an asset
* Constructs the proper URL with filename encoding and type parameter
*
* @param asset The asset to get URL for
* @param defaultType Default type if asset doesn't have tags (default: 'output')
* @returns Full URL for viewing/downloading the asset
*
* @example
* const url = getAssetUrl(asset)
* downloadFile(url, asset.name)
*/
export function getAssetUrl(
asset: AssetItem,
defaultType: 'input' | 'output' = 'output'
): string {
const assetType = getAssetType(asset, defaultType)
return api.apiURL(
`/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}`
)
}
11 changes: 11 additions & 0 deletions src/utils/typeGuardUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ResultItemType } from '@/schemas/apiSchema'

/**
* Check if an error is an AbortError triggered by `AbortController#abort`
Expand Down Expand Up @@ -49,3 +50,13 @@ export const isSlotObject = (obj: unknown): obj is INodeSlot => {
'boundingRect' in obj
)
}

/**
* Type guard to check if a string is a valid ResultItemType
* ResultItemType is used for asset categorization (input/output/temp)
*/
export const isResultItemType = (
value: string | undefined
): value is ResultItemType => {
return value === 'input' || value === 'output' || value === 'temp'
}
Loading