mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-23 05:47:34 +00:00
Compare commits
5 Commits
remove-usa
...
parallel-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7da951b90 | ||
|
|
16ddcfdbaf | ||
|
|
ef5198be25 | ||
|
|
38675e658f | ||
|
|
bd95150f82 |
@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DebugHelper } from './helpers/DebugHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
@@ -174,7 +173,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly debug: DebugHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -219,7 +217,6 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.debug = new DebugHelper(page, this.canvas)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
|
||||
export interface DebugScreenshotOptions {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
|
||||
export class DebugHelper {
|
||||
constructor(
|
||||
private page: Page,
|
||||
private canvas: Locator
|
||||
) {}
|
||||
|
||||
async addMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
async attachScreenshot(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
options?: DebugScreenshotOptions
|
||||
): Promise<void> {
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.addMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
if (options?.markers) {
|
||||
await this.removeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async saveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
}
|
||||
|
||||
async getCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async showCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
async hideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isReactive, isReadonly } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.linearToggleEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
localStorage.setItem('ff:model_upload_button_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.modelUploadButtonEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveFlag falls through to server when no override is set', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.assetRenameEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('direct server flags delegate override to api.getServerFeature', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation((path) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return 'overridden'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsPreviewMetadata).toBe('overridden')
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
/**
|
||||
* Known server feature flags (top-level, not extensions)
|
||||
@@ -24,6 +25,19 @@ export enum ServerFeatureFlag {
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
|
||||
*/
|
||||
function resolveFlag<T>(
|
||||
flagKey: string,
|
||||
remoteConfigValue: T | undefined,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const override = getDevOverride<T>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -39,38 +53,40 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
remoteConfig.value.model_upload_button_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ASSET_RENAME_ENABLED,
|
||||
remoteConfig.value.asset_rename_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
|
||||
remoteConfig.value.private_models_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
|
||||
remoteConfig.value.onboarding_survey_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
if (isNightly) return true
|
||||
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
|
||||
remoteConfig.value.linear_toggle_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
@@ -80,11 +96,12 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
// Only return true if authenticated config has been loaded.
|
||||
// This prevents race conditions where code checks this flag before
|
||||
// WorkspaceAuthGate has refreshed the config with auth.
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
@@ -93,9 +110,10 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
remoteConfig.value.user_secrets_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
|
||||
38
src/constants/toolkitNodes.ts
Normal file
38
src/constants/toolkitNodes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Toolkit (Essentials) node detection constants.
|
||||
*
|
||||
* Used by telemetry to track toolkit node adoption and popularity.
|
||||
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
|
||||
*
|
||||
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical node type names for individual toolkit nodes.
|
||||
*/
|
||||
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
|
||||
// Image Tools
|
||||
'ImageCrop',
|
||||
'ImageRotate',
|
||||
'ImageBlur',
|
||||
'ImageInvert',
|
||||
'ImageCompare',
|
||||
'Canny',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice',
|
||||
|
||||
// API Nodes
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
])
|
||||
|
||||
/**
|
||||
* python_module values that identify toolkit blueprint nodes.
|
||||
* Essentials blueprints are registered with node_pack 'comfy_essentials',
|
||||
* which maps to python_module on the node def.
|
||||
*/
|
||||
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -35,12 +36,32 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
showDialog: mockShowDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
|
||||
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateHistory = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn())
|
||||
const mockHasCategory = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
setAssetDeleting: mockSetAssetDeleting,
|
||||
updateHistory: mockUpdateHistory,
|
||||
updateInputs: mockUpdateInputs,
|
||||
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
||||
hasCategory: mockHasCategory
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: vi.fn()
|
||||
@@ -93,14 +114,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isResultItemType: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
|
||||
const mockGetAssetType = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
||||
getAssetType: vi.fn().mockReturnValue('input')
|
||||
getAssetType: mockGetAssetType
|
||||
}))
|
||||
|
||||
vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
deleteItem: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
|
||||
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
user: 'test-user'
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
@@ -115,7 +155,7 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
describe('useMediaAssetActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
capturedFilenames.values = []
|
||||
mockIsCloud.value = false
|
||||
@@ -218,4 +258,114 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - model cache invalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockInvalidateModelsForCategory.mockClear()
|
||||
mockSetAssetDeleting.mockClear()
|
||||
mockUpdateHistory.mockClear()
|
||||
mockUpdateInputs.mockClear()
|
||||
mockHasCategory.mockClear()
|
||||
// By default, hasCategory returns true for model categories
|
||||
mockHasCategory.mockImplementation(
|
||||
(tag: string) => tag === 'checkpoints' || tag === 'loras'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate model cache when deleting a model asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const modelAsset = createMockAsset({
|
||||
id: 'checkpoint-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(modelAsset)
|
||||
|
||||
// Only 'checkpoints' exists in cache; 'models' is excluded
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate multiple categories for multiple assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'loras'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
|
||||
})
|
||||
|
||||
it('should not invalidate model cache for non-model assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const inputAsset = createMockAsset({
|
||||
id: 'input-1',
|
||||
name: 'image.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(inputAsset)
|
||||
|
||||
// 'input' tag is excluded, so no cache invalidation
|
||||
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only invalidate categories that exist in cache', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
// hasCategory returns false for 'unknown-category'
|
||||
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
// Only checkpoints should be invalidated (unknown-category not in cache)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -639,6 +641,22 @@ export function useMediaAssetActions() {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
// Invalidate model caches for affected categories
|
||||
const modelCategories = new Set<string>()
|
||||
|
||||
for (const asset of assetArray) {
|
||||
for (const tag of asset.tags ?? []) {
|
||||
if (EXCLUDED_TAGS.has(tag)) continue
|
||||
if (assetsStore.hasCategory(tag)) {
|
||||
modelCategories.add(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of modelCategories) {
|
||||
assetsStore.invalidateModelsForCategory(category)
|
||||
}
|
||||
|
||||
// Show appropriate feedback based on results
|
||||
if (failed.length === 0) {
|
||||
toast.add({
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/topupTracker', () => ({
|
||||
checkForCompletedTopup: vi.fn(),
|
||||
clearTopupTracking: vi.fn(),
|
||||
startTopupTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: hoisted.mockNodeDefsByName
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
knownTemplateNames: new Set()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
function mockNode(
|
||||
type: string,
|
||||
isSubgraph = false
|
||||
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
|
||||
return {
|
||||
type,
|
||||
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
|
||||
let result = initial
|
||||
for (const node of hoisted.mockNodes) {
|
||||
result = reducer(result, node)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
}))
|
||||
|
||||
import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider'
|
||||
|
||||
describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
let provider: MixpanelTelemetryProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.mockNodes.length = 0
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
provider = new MixpanelTelemetryProvider()
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['LoadImage'] = {
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(false)
|
||||
expect(context.toolkit_node_names).toEqual([])
|
||||
expect(context.toolkit_node_count).toBe(0)
|
||||
})
|
||||
|
||||
it('detects individual toolkit nodes by type name', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('detects blueprint toolkit nodes via python_module', () => {
|
||||
const blueprintType = 'SubgraphBlueprint.text_to_image'
|
||||
hoisted.mockNodes.push(mockNode(blueprintType, true))
|
||||
hoisted.mockNodeDefsByName[blueprintType] = {
|
||||
name: blueprintType,
|
||||
python_module: 'comfy_essentials'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual([blueprintType])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(2)
|
||||
})
|
||||
|
||||
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
|
||||
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
|
||||
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
|
||||
name: 'RecraftRemoveBackgroundNode',
|
||||
python_module: 'comfy_extras.nodes_api',
|
||||
api_node: true
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_api_nodes).toBe(true)
|
||||
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
})
|
||||
|
||||
it('uses node.type as tracking name when nodeDef is missing', () => {
|
||||
hoisted.mockNodes.push(mockNode('ImageCrop'))
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
TOOLKIT_BLUEPRINT_MODULES,
|
||||
TOOLKIT_NODE_NAMES
|
||||
} from '@/constants/toolkitNodes'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
clearTopupTracking as clearTopupUtil,
|
||||
@@ -285,6 +289,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
}
|
||||
|
||||
@@ -432,10 +438,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
type NodeMetrics = {
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
toolkit_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
}
|
||||
|
||||
const nodeCounts = reduceAllNodes<NodeMetrics>(
|
||||
@@ -458,8 +467,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const isToolkitNode =
|
||||
TOOLKIT_NODE_NAMES.has(node.type) ||
|
||||
(nodeDef?.python_module !== undefined &&
|
||||
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
|
||||
if (isToolkitNode) {
|
||||
metrics.has_toolkit_nodes = true
|
||||
const trackingName = nodeDef?.name ?? node.type
|
||||
if (!metrics.toolkit_node_names.includes(trackingName)) {
|
||||
metrics.toolkit_node_names.push(trackingName)
|
||||
}
|
||||
}
|
||||
|
||||
metrics.custom_node_count += isCustomNode ? 1 : 0
|
||||
metrics.api_node_count += isApiNode ? 1 : 0
|
||||
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
|
||||
metrics.subgraph_count += isSubgraph ? 1 : 0
|
||||
metrics.total_node_count += 1
|
||||
|
||||
@@ -468,10 +490,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
{
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
toolkit_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface RunButtonProperties {
|
||||
subgraph_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
@@ -82,6 +84,9 @@ export interface ExecutionContext {
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
toolkit_node_count: number
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
|
||||
@@ -233,4 +233,37 @@ describe('API Feature Flags', () => {
|
||||
expect(flag.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('getServerFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('serverSupportsFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.serverSupportsFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('getServerFeature falls through when no override is set', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: 'server_value' }
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe('server_value')
|
||||
})
|
||||
|
||||
it('getServerFeature override works with numeric values', () => {
|
||||
api.serverFeatureFlags.value = { max_upload_size: 100 }
|
||||
localStorage.setItem('ff:max_upload_size', '999')
|
||||
|
||||
expect(api.getServerFeature('max_upload_size')).toBe(999)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
@@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns true if the feature is supported, false otherwise
|
||||
*/
|
||||
serverSupportsFeature(featureName: string): boolean {
|
||||
const override = getDevOverride<boolean>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName) === true
|
||||
}
|
||||
|
||||
@@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns The feature value or default
|
||||
*/
|
||||
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
|
||||
const override = getDevOverride<T>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
|
||||
}
|
||||
|
||||
|
||||
@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const createMockAsset = (id: string) => ({
|
||||
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
|
||||
id,
|
||||
name: `asset-${id}`,
|
||||
size: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['models'],
|
||||
tags,
|
||||
preview_url: `http://test.com/${id}`
|
||||
})
|
||||
|
||||
@@ -751,4 +751,103 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasCategory', () => {
|
||||
it('should return true for loaded categories', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for tag-based category when tag: prefix is not used', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
// hasCategory('models') checks for both 'models' and 'tag:models'
|
||||
expect(store.hasCategory('models')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unloaded categories', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
expect(store.hasCategory('unknown-category')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false after category is invalidated', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
|
||||
store.invalidateCategory('checkpoints')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateModelsForCategory', () => {
|
||||
it('should clear cache for category and trigger refetch on next access', async () => {
|
||||
const store = useAssetsStore()
|
||||
const initialAssets = [createMockAsset('initial-1')]
|
||||
const refreshedAssets = [
|
||||
createMockAsset('refreshed-1'),
|
||||
createMockAsset('refreshed-2')
|
||||
]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
initialAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(1)
|
||||
|
||||
store.invalidateModelsForCategory('checkpoints')
|
||||
|
||||
// Cache should be cleared
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
|
||||
|
||||
// Next fetch should get fresh data
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
refreshedAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should clear tag-based caches', async () => {
|
||||
const store = useAssetsStore()
|
||||
const tagAssets = [createMockAsset('tag-1'), createMockAsset('tag-2')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(tagAssets)
|
||||
await store.updateModelsForTag('checkpoints')
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
expect(store.getAssets('tag:checkpoints')).toHaveLength(2)
|
||||
expect(store.getAssets('tag:models')).toHaveLength(2)
|
||||
|
||||
store.invalidateModelsForCategory('checkpoints')
|
||||
|
||||
expect(store.getAssets('tag:checkpoints')).toEqual([])
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle unknown categories gracefully', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
expect(() =>
|
||||
store.invalidateModelsForCategory('unknown-category')
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
@@ -608,17 +620,30 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,11 +654,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {}
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,11 +670,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
@@ -718,12 +747,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
|
||||
// Model assets - actions
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,9 +215,26 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionErrorStore.clearAllErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
const jobId = e.detail.prompt_id
|
||||
queuedJobs.value[jobId] ??= { nodes: {} }
|
||||
clearInitializationByJobId(jobId)
|
||||
|
||||
// Only set activeJobId if idle or if this job's workflow matches the current canvas
|
||||
if (!activeJobId.value) {
|
||||
activeJobId.value = jobId
|
||||
} else {
|
||||
const newJobWorkflowId = jobIdToWorkflowId.value.get(jobId)
|
||||
const currentWorkflow = workflowStore.activeWorkflow
|
||||
if (
|
||||
newJobWorkflowId &&
|
||||
currentWorkflow &&
|
||||
String(
|
||||
currentWorkflow.activeState?.id ?? currentWorkflow.initialState?.id
|
||||
) === newJobWorkflowId
|
||||
) {
|
||||
activeJobId.value = jobId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
@@ -282,12 +299,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
// Update per-job progress (always)
|
||||
nodeProgressStatesByJob.value = {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
}
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// Only update the "active" progress states if this is the active job
|
||||
if (jobId === activeJobId.value) {
|
||||
nodeProgressStates.value = nodes
|
||||
}
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
@@ -424,20 +445,30 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
* Reset execution-related state after a run completes or is stopped.
|
||||
*/
|
||||
function resetExecutionState(jobIdParam?: string | null) {
|
||||
nodeProgressStates.value = {}
|
||||
const jobId = jobIdParam ?? activeJobId.value ?? null
|
||||
if (jobId) {
|
||||
const map = { ...nodeProgressStatesByJob.value }
|
||||
delete map[jobId]
|
||||
nodeProgressStatesByJob.value = map
|
||||
useJobPreviewStore().clearPreview(jobId)
|
||||
delete queuedJobs.value[jobId]
|
||||
}
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
|
||||
// Only clear active state if this was the active job
|
||||
if (jobId && jobId === activeJobId.value) {
|
||||
// Promote next running job if any
|
||||
const nextRunning = runningJobIds.value.find((id) => id !== jobId)
|
||||
if (nextRunning) {
|
||||
activeJobId.value = nextRunning
|
||||
nodeProgressStates.value =
|
||||
nodeProgressStatesByJob.value[nextRunning] ?? {}
|
||||
} else {
|
||||
activeJobId.value = null
|
||||
nodeProgressStates.value = {}
|
||||
_executingNodeProgress.value = null
|
||||
}
|
||||
executionErrorStore.clearPromptError()
|
||||
}
|
||||
activeJobId.value = null
|
||||
_executingNodeProgress.value = null
|
||||
executionErrorStore.clearPromptError()
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
|
||||
60
src/utils/devFeatureFlagOverride.test.ts
Normal file
60
src/utils/devFeatureFlagOverride.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
describe('getDevOverride', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns undefined when no override is set', () => {
|
||||
expect(getDevOverride('some_flag')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns parsed boolean true', () => {
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
expect(getDevOverride<boolean>('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns parsed boolean false', () => {
|
||||
localStorage.setItem('ff:some_flag', 'false')
|
||||
expect(getDevOverride<boolean>('some_flag')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns parsed number', () => {
|
||||
localStorage.setItem('ff:max_upload_size', '209715200')
|
||||
expect(getDevOverride<number>('max_upload_size')).toBe(209715200)
|
||||
})
|
||||
|
||||
it('returns parsed string', () => {
|
||||
localStorage.setItem('ff:some_flag', '"hello"')
|
||||
expect(getDevOverride<string>('some_flag')).toBe('hello')
|
||||
})
|
||||
|
||||
it('returns parsed object', () => {
|
||||
localStorage.setItem('ff:complex', '{"nested": true}')
|
||||
expect(getDevOverride<Record<string, boolean>>('complex')).toEqual({
|
||||
nested: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses ff: prefix for localStorage keys', () => {
|
||||
localStorage.setItem('some_flag', 'true')
|
||||
expect(getDevOverride('some_flag')).toBeUndefined()
|
||||
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
expect(getDevOverride('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns undefined and warns on invalid JSON', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
localStorage.setItem('ff:bad', 'True')
|
||||
|
||||
expect(getDevOverride('bad')).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ff] Invalid JSON for override "bad":',
|
||||
'True'
|
||||
)
|
||||
})
|
||||
})
|
||||
26
src/utils/devFeatureFlagOverride.ts
Normal file
26
src/utils/devFeatureFlagOverride.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const FF_PREFIX = 'ff:'
|
||||
|
||||
/**
|
||||
* Gets a dev-time feature flag override from localStorage.
|
||||
* Stripped from production builds via import.meta.env.DEV tree-shaking.
|
||||
*
|
||||
* Returns undefined (not null) as the "no override" sentinel because
|
||||
* null is a valid JSON value — JSON.parse('null') returns null.
|
||||
* Using undefined avoids ambiguity between "no override set" and
|
||||
* "override explicitly set to null".
|
||||
*
|
||||
* Usage in browser console:
|
||||
* localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
* localStorage.removeItem('ff:team_workspaces_enabled')
|
||||
*/
|
||||
export function getDevOverride<T>(flagKey: string): T | undefined {
|
||||
if (!import.meta.env.DEV) return undefined
|
||||
const raw = localStorage.getItem(`${FF_PREFIX}${flagKey}`)
|
||||
if (raw === null) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
console.warn(`[ff] Invalid JSON for override "${flagKey}":`, raw)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user