[feat] Add copy job ID action and shared asset utilities

This commit introduces the copy job ID feature for media assets along with
shared utility functions that will be used across multiple asset actions.

Features:
- Copy job ID to clipboard with OSS/Cloud compatibility
- Uses useCopyToClipboard composable for consistent UX

Shared Utilities:
- assetTypeUtil: Extract asset type from tags
- assetUrlUtil: Construct asset URLs for download/view
- typeGuardUtil: Add isResultItemType type guard

Refactoring:
- Unified asset deletion logic with deleteAssetApi helper
- Replaced inline URL construction with getAssetUrl utility
- Improved error handling with Cloud-specific warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-11-17 12:34:34 +09:00
parent c43a4990a9
commit 54a8d913f8
5 changed files with 139 additions and 89 deletions

View File

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

View File

@@ -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'
@@ -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
@@ -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)
@@ -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)
})
@@ -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({
@@ -134,61 +157,32 @@ export function useMediaAssetActions() {
const assetsStore = useAssetsStore()
try {
// Perform the deletion
await deleteAssetApi(asset, assetType)
// Update the appropriate store based on asset type
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')
}
await api.deleteItem('history', promptId)
// Update history assets in store after deletion
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
@@ -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) => {
@@ -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()
}

View File

@@ -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
}

View File

@@ -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}`
)
}

View File

@@ -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`
@@ -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'
}