Merge branch 'main' into clean-tsconfig
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
@@ -60,6 +60,7 @@ export default defineConfig([
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'public/auth-dev-sw.js',
|
||||
'public/auth-sw.js',
|
||||
'src/extensions/core/*',
|
||||
'src/scripts/*',
|
||||
|
||||
@@ -42,8 +42,9 @@ const config: KnipConfig = {
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Service worker - registered at runtime via navigator.serviceWorker.register()
|
||||
'public/auth-sw.js'
|
||||
// Service workers - registered at runtime via navigator.serviceWorker.register()
|
||||
'public/auth-sw.js',
|
||||
'public/auth-dev-sw.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
168
public/auth-dev-sw.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview Authentication Service Worker (Development Version)
|
||||
* Intercepts /api/view requests and rewrites them to a configurable base URL with auth token.
|
||||
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
|
||||
* This version is used in development to proxy requests to staging/test environments.
|
||||
* Default base URL: https://testcloud.comfy.org (configurable via SET_BASE_URL message)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthHeader
|
||||
* @property {string} Authorization - Bearer token for authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CachedAuth
|
||||
* @property {AuthHeader|null} header
|
||||
* @property {number} expiresAt - Timestamp when cache expires
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
|
||||
|
||||
/** @type {CachedAuth|null} */
|
||||
let authCache = null
|
||||
|
||||
/** @type {Promise<AuthHeader|null>|null} */
|
||||
let authRequestInFlight = null
|
||||
|
||||
/** @type {string} */
|
||||
let baseUrl = 'https://testcloud.comfy.org'
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
|
||||
authCache = null
|
||||
authRequestInFlight = null
|
||||
}
|
||||
|
||||
if (event.data.type === 'SET_BASE_URL') {
|
||||
baseUrl = event.data.baseUrl
|
||||
console.log('[Auth DEV SW] Base URL set to:', baseUrl)
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
if (
|
||||
!url.pathname.startsWith('/api/view') &&
|
||||
!url.pathname.startsWith('/api/viewvideo')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Rewrite URL to use configured base URL (default: stagingcloud.comfy.org)
|
||||
const originalUrl = new URL(event.request.url)
|
||||
const rewrittenUrl = new URL(
|
||||
originalUrl.pathname + originalUrl.search,
|
||||
baseUrl
|
||||
)
|
||||
|
||||
const authHeader = await getAuthHeader()
|
||||
|
||||
// With mode: 'no-cors', Authorization headers are stripped by the browser
|
||||
// So we add the token to the URL as a query parameter instead
|
||||
if (authHeader && authHeader.Authorization) {
|
||||
const token = authHeader.Authorization.replace('Bearer ', '')
|
||||
rewrittenUrl.searchParams.set('token', token)
|
||||
}
|
||||
|
||||
// Cross-origin request requires no-cors mode
|
||||
// - mode: 'no-cors' allows cross-origin fetches without CORS headers
|
||||
// - Returns opaque response, which works fine for images/videos/audio
|
||||
// - Auth token is sent via query parameter since headers are stripped in no-cors mode
|
||||
// - Server may return redirect to GCS, which will be followed automatically
|
||||
return fetch(rewrittenUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
mode: 'no-cors'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Auth DEV SW] Request failed:', error)
|
||||
const originalUrl = new URL(event.request.url)
|
||||
const rewrittenUrl = new URL(
|
||||
originalUrl.pathname + originalUrl.search,
|
||||
baseUrl
|
||||
)
|
||||
return fetch(rewrittenUrl, {
|
||||
mode: 'no-cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets auth header from cache or requests from main thread
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function getAuthHeader() {
|
||||
// Return cached value if valid
|
||||
if (authCache && authCache.expiresAt > Date.now()) {
|
||||
return authCache.header
|
||||
}
|
||||
|
||||
// Clear expired cache
|
||||
if (authCache) {
|
||||
authCache = null
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (authRequestInFlight) {
|
||||
return authRequestInFlight
|
||||
}
|
||||
|
||||
authRequestInFlight = requestAuthHeaderFromMainThread()
|
||||
const header = await authRequestInFlight
|
||||
authRequestInFlight = null
|
||||
|
||||
// Cache the result
|
||||
if (header) {
|
||||
authCache = {
|
||||
header,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
}
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests auth header from main thread via MessageChannel
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function requestAuthHeaderFromMainThread() {
|
||||
const clients = await self.clients.matchAll()
|
||||
if (clients.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId
|
||||
|
||||
messageChannel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(event.data.authHeader)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
console.error(
|
||||
'[Auth DEV SW] Timeout waiting for auth header from main thread'
|
||||
)
|
||||
resolve(null)
|
||||
}, 1000)
|
||||
|
||||
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
|
||||
messageChannel.port2
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
@@ -42,7 +42,6 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
if (isElectron()) {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
:max-selected-labels="3"
|
||||
:display="display"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +128,17 @@ export function useTemplateFiltering(
|
||||
})
|
||||
})
|
||||
|
||||
const getVramMetric = (template: TemplateInfo) => {
|
||||
if (
|
||||
typeof template.vram === 'number' &&
|
||||
Number.isFinite(template.vram) &&
|
||||
template.vram > 0
|
||||
) {
|
||||
return template.vram
|
||||
}
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByLicenses.value]
|
||||
|
||||
@@ -145,9 +156,21 @@ export function useTemplateFiltering(
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
case 'vram-low-to-high':
|
||||
// TODO: Implement VRAM sorting when VRAM data is available
|
||||
// For now, keep original order
|
||||
return templates
|
||||
return templates.sort((a, b) => {
|
||||
const vramA = getVramMetric(a)
|
||||
const vramB = getVramMetric(b)
|
||||
|
||||
if (vramA === vramB) {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
}
|
||||
|
||||
if (vramA === Number.POSITIVE_INFINITY) return 1
|
||||
if (vramB === Number.POSITIVE_INFINITY) return -1
|
||||
|
||||
return vramA - vramB
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a: any, b: any) => {
|
||||
const sizeA =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
@@ -15,19 +17,6 @@ function filterByCategory(category: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function filterByQuery(query: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (!query) return true
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const description = getAssetDescription(asset)
|
||||
return (
|
||||
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function filterByFileFormats(formats: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (formats.length === 0) return true
|
||||
@@ -160,9 +149,31 @@ export function useAssetBrowser(
|
||||
return assets.value.filter(filterByCategory(selectedCategory.value))
|
||||
})
|
||||
|
||||
const fuseOptions: UseFuseOptions<AssetItem> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.4 },
|
||||
{ name: 'tags', weight: 0.3 }
|
||||
],
|
||||
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
|
||||
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
|
||||
includeScore: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
const { results: fuseResults } = useFuse(
|
||||
searchQuery,
|
||||
categoryFilteredAssets,
|
||||
fuseOptions
|
||||
)
|
||||
|
||||
const searchFiltered = computed(() =>
|
||||
fuseResults.value.map((result) => result.item)
|
||||
)
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = categoryFilteredAssets.value
|
||||
.filter(filterByQuery(searchQuery.value))
|
||||
const filtered = searchFiltered.value
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
|
||||
|
||||
@@ -13,7 +13,34 @@ async function registerAuthServiceWorker(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.serviceWorker.register('/auth-sw.js')
|
||||
// Use dev service worker in development mode (rewrites to configured backend URL with token in query param)
|
||||
// Use production service worker in production (same-origin requests with Authorization header)
|
||||
const swPath = import.meta.env.DEV ? '/auth-dev-sw.js' : '/auth-sw.js'
|
||||
const registration = await navigator.serviceWorker.register(swPath)
|
||||
|
||||
// Configure base URL for dev service worker
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Auth DEV SW] Registering development serviceworker')
|
||||
// Use the same URL that Vite proxy is using
|
||||
const baseUrl = __DEV_SERVER_COMFYUI_URL__
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: 'SET_BASE_URL',
|
||||
baseUrl
|
||||
})
|
||||
|
||||
// Also set base URL when service worker becomes active
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
newWorker?.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: 'SET_BASE_URL',
|
||||
baseUrl
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setupAuthHeaderProvider()
|
||||
setupCacheInvalidation()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @fileoverview Adapter to convert V2 history format to V1 format
|
||||
* @module platform/remote/comfyui/history/adapters/v2ToV1Adapter
|
||||
*
|
||||
* Converts cloud API V2 response format to the V1 format expected by the app.
|
||||
*/
|
||||
|
||||
import type { HistoryTaskItem, TaskPrompt } from '../types/historyV1Types'
|
||||
import type {
|
||||
HistoryResponseV2,
|
||||
RawHistoryItemV2,
|
||||
TaskOutput,
|
||||
TaskPromptV2
|
||||
} from '../types/historyV2Types'
|
||||
|
||||
/**
|
||||
* Maps V2 prompt format to V1 prompt tuple format.
|
||||
*/
|
||||
function mapPromptV2toV1(
|
||||
promptV2: TaskPromptV2,
|
||||
outputs: TaskOutput
|
||||
): TaskPrompt {
|
||||
const outputNodesIds = Object.keys(outputs)
|
||||
const { priority, prompt_id, extra_data } = promptV2
|
||||
return [priority, prompt_id, {}, extra_data, outputNodesIds]
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps V2 history format to V1 history format.
|
||||
*/
|
||||
export function mapHistoryV2toHistory(
|
||||
historyV2Response: HistoryResponseV2
|
||||
): HistoryTaskItem[] {
|
||||
return historyV2Response.history.map(
|
||||
({ prompt, status, outputs, meta }: RawHistoryItemV2): HistoryTaskItem => ({
|
||||
taskType: 'History' as const,
|
||||
prompt: mapPromptV2toV1(prompt, outputs),
|
||||
status,
|
||||
outputs,
|
||||
meta
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @fileoverview V1 History Fetcher - Desktop/localhost API
|
||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV1
|
||||
*
|
||||
* Fetches history directly from V1 API endpoint.
|
||||
* Used by desktop and localhost distributions.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
HistoryV1Response
|
||||
} from '../types/historyV1Types'
|
||||
|
||||
/**
|
||||
* Fetches history from V1 API endpoint
|
||||
* @param api - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @returns Promise resolving to V1 history response
|
||||
*/
|
||||
export async function fetchHistoryV1(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history?max_items=${maxItems}`)
|
||||
const json: Record<
|
||||
string,
|
||||
Omit<HistoryTaskItem, 'taskType'>
|
||||
> = await res.json()
|
||||
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview V2 History Fetcher - Cloud API with adapter
|
||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV2
|
||||
*
|
||||
* Fetches history from V2 API endpoint and converts to V1 format.
|
||||
* Used exclusively by cloud distribution.
|
||||
*/
|
||||
|
||||
import { mapHistoryV2toHistory } from '../adapters/v2ToV1Adapter'
|
||||
import type { HistoryV1Response } from '../types/historyV1Types'
|
||||
import type { HistoryResponseV2 } from '../types/historyV2Types'
|
||||
|
||||
/**
|
||||
* Fetches history from V2 API endpoint and adapts to V1 format
|
||||
* @param fetchApi - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @returns Promise resolving to V1 history response (adapted from V2)
|
||||
*/
|
||||
export async function fetchHistoryV2(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history_v2?max_items=${maxItems}`)
|
||||
const rawData: HistoryResponseV2 = await res.json()
|
||||
const adaptedHistory = mapHistoryV2toHistory(rawData)
|
||||
return { History: adaptedHistory }
|
||||
}
|
||||
29
src/platform/remote/comfyui/history/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview History API module - Distribution-aware exports
|
||||
* @module platform/remote/comfyui/history
|
||||
*
|
||||
* This module provides a unified history fetching interface that automatically
|
||||
* uses the correct implementation based on build-time distribution constant.
|
||||
*
|
||||
* - Cloud builds: Uses V2 API with adapter (tree-shakes V1 fetcher)
|
||||
* - Desktop/localhost builds: Uses V1 API directly (tree-shakes V2 fetcher + adapter)
|
||||
*
|
||||
* The rest of the application only needs to import from this module and use
|
||||
* V1 types - all distribution-specific details are encapsulated here.
|
||||
*/
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { fetchHistoryV1 } from './fetchers/fetchHistoryV1'
|
||||
import { fetchHistoryV2 } from './fetchers/fetchHistoryV2'
|
||||
|
||||
/**
|
||||
* Fetches history using the appropriate API for the current distribution.
|
||||
* Build-time constant enables dead code elimination - only one implementation
|
||||
* will be included in the final bundle.
|
||||
*/
|
||||
export const fetchHistory = isCloud ? fetchHistoryV2 : fetchHistoryV1
|
||||
|
||||
/**
|
||||
* Export only V1 types publicly - consumers don't need to know about V2
|
||||
*/
|
||||
export type * from './types'
|
||||
15
src/platform/remote/comfyui/history/types/historyV1Types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview History V1 types - Public interface used throughout the app
|
||||
* @module platform/remote/comfyui/history/types/historyV1Types
|
||||
*
|
||||
* These types represent the V1 history format that the application expects.
|
||||
* Both desktop (direct V1 API) and cloud (V2 API + adapter) return data in this format.
|
||||
*/
|
||||
|
||||
import type { HistoryTaskItem, TaskPrompt } from '@/schemas/apiSchema'
|
||||
|
||||
export interface HistoryV1Response {
|
||||
History: HistoryTaskItem[]
|
||||
}
|
||||
|
||||
export type { HistoryTaskItem, TaskPrompt }
|
||||
45
src/platform/remote/comfyui/history/types/historyV2Types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview History V2 types and schemas - Internal cloud API format
|
||||
* @module platform/remote/comfyui/history/types/historyV2Types
|
||||
*
|
||||
* These types and schemas represent the V2 history format returned by the cloud API.
|
||||
* They are only used internally and are converted to V1 format via adapter.
|
||||
*
|
||||
* IMPORTANT: These types should NOT be used outside this history module.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
zExtraData,
|
||||
zPromptId,
|
||||
zQueueIndex,
|
||||
zStatus,
|
||||
zTaskMeta,
|
||||
zTaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
|
||||
const zTaskPromptV2 = z.object({
|
||||
priority: zQueueIndex,
|
||||
prompt_id: zPromptId,
|
||||
extra_data: zExtraData
|
||||
})
|
||||
|
||||
const zRawHistoryItemV2 = z.object({
|
||||
prompt_id: zPromptId,
|
||||
prompt: zTaskPromptV2,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
const zHistoryResponseV2 = z.object({
|
||||
history: z.array(zRawHistoryItemV2)
|
||||
})
|
||||
|
||||
export type TaskPromptV2 = z.infer<typeof zTaskPromptV2>
|
||||
export type RawHistoryItemV2 = z.infer<typeof zRawHistoryItemV2>
|
||||
export type HistoryResponseV2 = z.infer<typeof zHistoryResponseV2>
|
||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
||||
|
||||
export { zRawHistoryItemV2 }
|
||||
9
src/platform/remote/comfyui/history/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Public history types export
|
||||
* @module platform/remote/comfyui/history/types
|
||||
*
|
||||
* Only V1 types are exported publicly - the rest of the app
|
||||
* should never need to know about V2 types or implementation details.
|
||||
*/
|
||||
|
||||
export type * from './historyV1Types'
|
||||
@@ -18,6 +18,10 @@ export interface TemplateInfo {
|
||||
date?: string
|
||||
useCase?: string
|
||||
license?: string
|
||||
/**
|
||||
* Estimated VRAM requirement in bytes.
|
||||
*/
|
||||
vram?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
class="max-w-[20em] min-w-[8em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
@@ -95,7 +96,8 @@
|
||||
class="max-w-[20em] min-w-[8em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -103,10 +103,10 @@ const inputNumberPt = useNumberWidgetButtonPt({
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
<span class="pi pi-plus text-sm" />
|
||||
<span class="pi pi-plus text-sm text-button-icon" />
|
||||
</template>
|
||||
<template #decrementicon>
|
||||
<span class="pi pi-minus text-sm" />
|
||||
<span class="pi pi-minus text-sm text-button-icon" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
size="small"
|
||||
display="chip"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="onChange"
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
class="w-full text-xs"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
|
||||
@@ -11,8 +11,8 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
|
||||
const zNodeType = z.string()
|
||||
const zQueueIndex = z.number()
|
||||
const zPromptId = z.string()
|
||||
export const zQueueIndex = z.number()
|
||||
export const zPromptId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
@@ -170,10 +170,10 @@ const zExtraPngInfo = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExtraData = z.object({
|
||||
export const zExtraData = z.object({
|
||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
client_id: z.string().optional()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
@@ -210,7 +210,7 @@ const zStatusMessage = z.union([
|
||||
zExecutionErrorMessage
|
||||
])
|
||||
|
||||
const zStatus = z.object({
|
||||
export const zStatus = z.object({
|
||||
status_str: z.enum(['success', 'error']),
|
||||
completed: z.boolean(),
|
||||
messages: z.array(zStatusMessage)
|
||||
@@ -239,7 +239,7 @@ const zPendingTaskItem = z.object({
|
||||
prompt: zTaskPrompt
|
||||
})
|
||||
|
||||
const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
export const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||
|
||||
const zNodeOutputsMeta = z.object({
|
||||
node_id: zNodeId,
|
||||
@@ -248,7 +248,7 @@ const zNodeOutputsMeta = z.object({
|
||||
read_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
export const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
||||
|
||||
const zHistoryTaskItem = z.object({
|
||||
taskType: z.literal('History'),
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { fetchHistory } from '@/platform/remote/comfyui/history'
|
||||
|
||||
interface QueuePromptRequestBody {
|
||||
client_id: string
|
||||
@@ -900,14 +901,7 @@ export class ComfyApi extends EventTarget {
|
||||
max_items: number = 200
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history?max_items=${max_items}`)
|
||||
const json: Promise<HistoryTaskItem[]> = await res.json()
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
}
|
||||
return await fetchHistory(this.fetchApi.bind(this), max_items)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return { History: [] }
|
||||
|
||||
4
src/vite-env.d.ts
vendored
@@ -16,4 +16,8 @@ declare global {
|
||||
interface Window {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
}
|
||||
|
||||
const __DEV_SERVER_COMFYUI_URL__: string
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
375
tests-ui/fixtures/historyFixtures.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @fileoverview Test fixtures for history tests.
|
||||
*/
|
||||
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
||||
import type { HistoryTaskItem } from '@/schemas/apiSchema'
|
||||
|
||||
/**
|
||||
* V1 API raw response format (object with prompt IDs as keys)
|
||||
*/
|
||||
export const historyV1RawResponse: Record<
|
||||
string,
|
||||
Omit<HistoryTaskItem, 'taskType'>
|
||||
> = {
|
||||
'complete-item-id': {
|
||||
prompt: [
|
||||
24,
|
||||
'complete-item-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'test-client',
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
||||
revision: 0,
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
},
|
||||
['9']
|
||||
],
|
||||
outputs: {
|
||||
'9': {
|
||||
images: [
|
||||
{
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_start',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
||||
],
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
},
|
||||
meta: {
|
||||
'9': {
|
||||
node_id: '9',
|
||||
display_node: '9'
|
||||
}
|
||||
}
|
||||
},
|
||||
'no-status-id': {
|
||||
prompt: [
|
||||
23,
|
||||
'no-status-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'inference'
|
||||
},
|
||||
['10']
|
||||
],
|
||||
outputs: {
|
||||
'10': {
|
||||
images: []
|
||||
}
|
||||
},
|
||||
status: undefined,
|
||||
meta: {
|
||||
'10': {
|
||||
node_id: '10',
|
||||
display_node: '10'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 response with multiple edge cases:
|
||||
* - Item 0: Complete with all fields
|
||||
* - Item 1: Missing optional status field
|
||||
* - Item 2: Missing optional meta field
|
||||
* - Item 3: Multiple output nodes
|
||||
*/
|
||||
export const historyV2Fixture: HistoryResponseV2 = {
|
||||
history: [
|
||||
{
|
||||
prompt_id: 'complete-item-id',
|
||||
prompt: {
|
||||
priority: 24,
|
||||
prompt_id: 'complete-item-id',
|
||||
extra_data: {
|
||||
client_id: 'test-client',
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
||||
revision: 0,
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'9': {
|
||||
images: [
|
||||
{
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_start',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
||||
],
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
},
|
||||
meta: {
|
||||
'9': {
|
||||
node_id: '9',
|
||||
display_node: '9'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prompt_id: 'no-status-id',
|
||||
prompt: {
|
||||
priority: 23,
|
||||
prompt_id: 'no-status-id',
|
||||
extra_data: {
|
||||
client_id: 'inference'
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'10': {
|
||||
images: []
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
'10': {
|
||||
node_id: '10',
|
||||
display_node: '10'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prompt_id: 'no-meta-id',
|
||||
prompt: {
|
||||
priority: 22,
|
||||
prompt_id: 'no-meta-id',
|
||||
extra_data: {
|
||||
client_id: 'web-ui'
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'11': {
|
||||
audio: []
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'error',
|
||||
completed: false,
|
||||
messages: []
|
||||
}
|
||||
},
|
||||
{
|
||||
prompt_id: 'multi-output-id',
|
||||
prompt: {
|
||||
priority: 21,
|
||||
prompt_id: 'multi-output-id',
|
||||
extra_data: {
|
||||
client_id: 'batch-processor'
|
||||
}
|
||||
},
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
|
||||
},
|
||||
'9': {
|
||||
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
|
||||
},
|
||||
'12': {
|
||||
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
meta: {
|
||||
'3': { node_id: '3', display_node: '3' },
|
||||
'9': { node_id: '9', display_node: '9' },
|
||||
'12': { node_id: '12', display_node: '12' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected V1 transformation of historyV2Fixture
|
||||
*/
|
||||
export const expectedV1Fixture: HistoryTaskItem[] = [
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
24,
|
||||
'complete-item-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'test-client',
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
||||
revision: 0,
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
},
|
||||
['9']
|
||||
],
|
||||
outputs: {
|
||||
'9': {
|
||||
images: [
|
||||
{
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_start',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
||||
],
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
},
|
||||
meta: {
|
||||
'9': {
|
||||
node_id: '9',
|
||||
display_node: '9'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
23,
|
||||
'no-status-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'inference'
|
||||
},
|
||||
['10']
|
||||
],
|
||||
outputs: {
|
||||
'10': {
|
||||
images: []
|
||||
}
|
||||
},
|
||||
status: undefined,
|
||||
meta: {
|
||||
'10': {
|
||||
node_id: '10',
|
||||
display_node: '10'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
22,
|
||||
'no-meta-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'web-ui'
|
||||
},
|
||||
['11']
|
||||
],
|
||||
outputs: {
|
||||
'11': {
|
||||
audio: []
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'error',
|
||||
completed: false,
|
||||
messages: []
|
||||
},
|
||||
meta: undefined
|
||||
},
|
||||
{
|
||||
taskType: 'History',
|
||||
prompt: [
|
||||
21,
|
||||
'multi-output-id',
|
||||
{},
|
||||
{
|
||||
client_id: 'batch-processor'
|
||||
},
|
||||
['3', '9', '12']
|
||||
],
|
||||
outputs: {
|
||||
'3': {
|
||||
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
|
||||
},
|
||||
'9': {
|
||||
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
|
||||
},
|
||||
'12': {
|
||||
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
|
||||
}
|
||||
},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
meta: {
|
||||
'3': { node_id: '3', display_node: '3' },
|
||||
'9': { node_id: '9', display_node: '9' },
|
||||
'12': { node_id: '12', display_node: '12' }
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -136,8 +136,8 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('searches across asset name', async () => {
|
||||
describe('Fuzzy Search Functionality', () => {
|
||||
it('searches across asset name with exact match', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'realistic_vision.safetensors' }),
|
||||
createApiAsset({ name: 'anime_style.ckpt' }),
|
||||
@@ -149,45 +149,148 @@ describe('useAssetBrowser', () => {
|
||||
searchQuery.value = 'realistic'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
|
||||
expect(
|
||||
filteredAssets.value.every((asset) =>
|
||||
filteredAssets.value.some((asset) =>
|
||||
asset.name.toLowerCase().includes('realistic')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('searches in user metadata description', async () => {
|
||||
it('searches across asset tags', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
name: 'model1.safetensors',
|
||||
user_metadata: { description: 'fantasy artwork model' }
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'model2.safetensors',
|
||||
user_metadata: { description: 'portrait photography' }
|
||||
tags: ['models', 'loras']
|
||||
})
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'fantasy'
|
||||
searchQuery.value = 'checkpoints'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
|
||||
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
|
||||
expect(filteredAssets.value[0].tags).toContain('checkpoints')
|
||||
})
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
it('supports fuzzy matching with typos', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'checkpoint_model.safetensors' }),
|
||||
createApiAsset({ name: 'lora_model.safetensors' })
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
// Intentional typo - fuzzy search should still find it
|
||||
searchQuery.value = 'chckpoint'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
|
||||
expect(filteredAssets.value[0].name).toContain('checkpoint')
|
||||
})
|
||||
|
||||
it('handles empty search by returning all assets', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'test1.safetensors' }),
|
||||
createApiAsset({ name: 'test2.safetensors' })
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = ''
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles no search results', async () => {
|
||||
const assets = [createApiAsset({ name: 'test.safetensors' })]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'nonexistent'
|
||||
searchQuery.value = 'completelydifferentstring123'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('performs case-insensitive search', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'RealisticVision.safetensors' }),
|
||||
createApiAsset({ name: 'anime_style.ckpt' })
|
||||
]
|
||||
|
||||
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
|
||||
|
||||
searchQuery.value = 'REALISTIC'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
|
||||
expect(filteredAssets.value[0].name).toContain('Realistic')
|
||||
})
|
||||
|
||||
it('combines fuzzy search with format filter', async () => {
|
||||
const assets = [
|
||||
createApiAsset({ name: 'my_checkpoint_model.safetensors' }),
|
||||
createApiAsset({ name: 'my_checkpoint_model.ckpt' }),
|
||||
createApiAsset({ name: 'different_lora.safetensors' })
|
||||
]
|
||||
|
||||
const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser(
|
||||
ref(assets)
|
||||
)
|
||||
|
||||
searchQuery.value = 'checkpoint'
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: ['safetensors'],
|
||||
baseModels: []
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1)
|
||||
expect(
|
||||
filteredAssets.value.every((asset) =>
|
||||
asset.name.endsWith('.safetensors')
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
filteredAssets.value.some((asset) => asset.name.includes('checkpoint'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('combines fuzzy search with base model filter', async () => {
|
||||
const assets = [
|
||||
createApiAsset({
|
||||
name: 'realistic_sd15.safetensors',
|
||||
user_metadata: { base_model: 'SD1.5' }
|
||||
}),
|
||||
createApiAsset({
|
||||
name: 'realistic_sdxl.safetensors',
|
||||
user_metadata: { base_model: 'SDXL' }
|
||||
})
|
||||
]
|
||||
|
||||
const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser(
|
||||
ref(assets)
|
||||
)
|
||||
|
||||
searchQuery.value = 'realistic'
|
||||
updateFilters({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: ['SDXL']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(filteredAssets.value).toHaveLength(1)
|
||||
expect(filteredAssets.value[0].name).toBe('realistic_sdxl.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined Search and Filtering', () => {
|
||||
|
||||
231
tests-ui/tests/composables/useTemplateFiltering.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
|
||||
const gb = (value: number) => value * 1024 ** 3
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'missing-vram',
|
||||
description: 'no vram value',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'highest-vram',
|
||||
description: 'high usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(12)
|
||||
},
|
||||
{
|
||||
name: 'mid-vram',
|
||||
description: 'medium usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(7.5)
|
||||
},
|
||||
{
|
||||
name: 'low-vram',
|
||||
description: 'low usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: gb(5)
|
||||
},
|
||||
{
|
||||
name: 'zero-vram',
|
||||
description: 'unknown usage',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
vram: 0
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'vram-low-to-high'
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'low-vram',
|
||||
'mid-vram',
|
||||
'highest-vram',
|
||||
'missing-vram',
|
||||
'zero-vram'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by search text, models, tags, and license with debounce handling', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'api-template',
|
||||
description: 'Enterprise API workflow for video',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
tags: ['API', 'Video'],
|
||||
models: ['Flux'],
|
||||
date: '2024-06-01',
|
||||
vram: 15 * 1024 ** 3
|
||||
},
|
||||
{
|
||||
name: 'portrait-flow',
|
||||
description: 'Portrait template tuned for SDXL',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
tags: ['Portrait'],
|
||||
models: ['SDXL'],
|
||||
date: '2024-05-15',
|
||||
vram: 10 * 1024 ** 3
|
||||
},
|
||||
{
|
||||
name: 'landscape-lite',
|
||||
description: 'Lightweight landscape generator',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
tags: ['Landscape'],
|
||||
models: ['SDXL', 'Flux'],
|
||||
date: '2024-04-20'
|
||||
}
|
||||
])
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
removeUseCaseFilter,
|
||||
resetFilters
|
||||
} = useTemplateFiltering(templates)
|
||||
|
||||
expect(totalCount.value).toBe(3)
|
||||
expect(availableModels.value).toEqual(['Flux', 'SDXL'])
|
||||
expect(availableUseCases.value).toEqual([
|
||||
'API',
|
||||
'Landscape',
|
||||
'Portrait',
|
||||
'Video'
|
||||
])
|
||||
expect(availableLicenses.value).toEqual([
|
||||
'Open Source',
|
||||
'Closed Source (API Nodes)'
|
||||
])
|
||||
|
||||
searchQuery.value = 'enterprise'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'api-template'
|
||||
])
|
||||
|
||||
selectedLicenses.value = ['Closed Source (API Nodes)']
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'api-template'
|
||||
])
|
||||
|
||||
selectedModels.value = ['Flux']
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'api-template'
|
||||
])
|
||||
|
||||
selectedUseCases.value = ['Video']
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'api-template'
|
||||
])
|
||||
expect(filteredCount.value).toBe(1)
|
||||
|
||||
removeUseCaseFilter('Video')
|
||||
await nextTick()
|
||||
expect(selectedUseCases.value).toHaveLength(0)
|
||||
|
||||
resetFilters()
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'api-template',
|
||||
'portrait-flow',
|
||||
'landscape-lite'
|
||||
])
|
||||
})
|
||||
|
||||
it('supports alphabetical, newest, and size-based sorting options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'zeta-extended',
|
||||
description: 'older template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
size: 300
|
||||
},
|
||||
{
|
||||
name: 'alpha-starter',
|
||||
description: 'new template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-07-01',
|
||||
size: 100
|
||||
},
|
||||
{
|
||||
name: 'beta-pro',
|
||||
description: 'mid template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-05-01',
|
||||
size: 200
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
// default is 'newest'
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'alpha-starter',
|
||||
'beta-pro',
|
||||
'zeta-extended'
|
||||
])
|
||||
|
||||
sortBy.value = 'alphabetical'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'alpha-starter',
|
||||
'beta-pro',
|
||||
'zeta-extended'
|
||||
])
|
||||
|
||||
sortBy.value = 'model-size-low-to-high'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'alpha-starter',
|
||||
'beta-pro',
|
||||
'zeta-extended'
|
||||
])
|
||||
|
||||
sortBy.value = 'default'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'zeta-extended',
|
||||
'alpha-starter',
|
||||
'beta-pro'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for V2 to V1 history adapter.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapHistoryV2toHistory } from '@/platform/remote/comfyui/history/adapters/v2ToV1Adapter'
|
||||
import { zRawHistoryItemV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
||||
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
||||
|
||||
import {
|
||||
expectedV1Fixture,
|
||||
historyV2Fixture
|
||||
} from '@tests-ui/fixtures/historyFixtures'
|
||||
|
||||
describe('mapHistoryV2toHistory', () => {
|
||||
describe('fixture validation', () => {
|
||||
it('should have valid fixture data', () => {
|
||||
// Validate all items in the fixture to ensure test data is correct
|
||||
historyV2Fixture.history.forEach((item: unknown) => {
|
||||
expect(() => zRawHistoryItemV2.parse(item)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a complete V2 history response with edge cases', () => {
|
||||
const history = mapHistoryV2toHistory(historyV2Fixture)
|
||||
|
||||
it('should transform all items to V1 format with correct structure', () => {
|
||||
expect(history).toEqual(expectedV1Fixture)
|
||||
})
|
||||
|
||||
it('should add taskType "History" to all items', () => {
|
||||
history.forEach((item) => {
|
||||
expect(item.taskType).toBe('History')
|
||||
})
|
||||
})
|
||||
|
||||
it('should transform prompt to V1 tuple [priority, id, {}, extra_data, outputNodeIds]', () => {
|
||||
const firstItem = history[0]
|
||||
|
||||
expect(firstItem.prompt[0]).toBe(24)
|
||||
expect(firstItem.prompt[1]).toBe('complete-item-id')
|
||||
expect(firstItem.prompt[2]).toEqual({}) // history v2 does not return this data
|
||||
expect(firstItem.prompt[3]).toMatchObject({ client_id: 'test-client' })
|
||||
expect(firstItem.prompt[4]).toEqual(['9'])
|
||||
})
|
||||
|
||||
it('should handle missing optional status field', () => {
|
||||
expect(history[1].prompt[1]).toBe('no-status-id')
|
||||
expect(history[1].status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle missing optional meta field', () => {
|
||||
expect(history[2].prompt[1]).toBe('no-meta-id')
|
||||
expect(history[2].meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should derive output node IDs from outputs object keys', () => {
|
||||
const multiOutputItem = history[3]
|
||||
|
||||
expect(multiOutputItem.prompt[4]).toEqual(
|
||||
expect.arrayContaining(['3', '9', '12'])
|
||||
)
|
||||
expect(multiOutputItem.prompt[4]).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('given empty history array', () => {
|
||||
it('should return empty array', () => {
|
||||
const emptyResponse: HistoryResponseV2 = { history: [] }
|
||||
const history = mapHistoryV2toHistory(emptyResponse)
|
||||
|
||||
expect(history).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('given empty outputs object', () => {
|
||||
it('should return empty array for output node IDs', () => {
|
||||
const v2Response: HistoryResponseV2 = {
|
||||
history: [
|
||||
{
|
||||
prompt_id: 'test-id',
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'test' }
|
||||
},
|
||||
outputs: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const history = mapHistoryV2toHistory(v2Response)
|
||||
|
||||
expect(history[0].prompt[4]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('given missing client_id', () => {
|
||||
it('should accept history items without client_id', () => {
|
||||
const v2Response: HistoryResponseV2 = {
|
||||
history: [
|
||||
{
|
||||
prompt_id: 'test-id',
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: {}
|
||||
},
|
||||
outputs: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const history = mapHistoryV2toHistory(v2Response)
|
||||
|
||||
expect(history[0].prompt[3].client_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for V1 history fetcher.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchHistoryV1 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV1'
|
||||
|
||||
import { historyV1RawResponse } from '@tests-ui/fixtures/historyFixtures'
|
||||
|
||||
describe('fetchHistoryV1', () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: async () => historyV1RawResponse
|
||||
})
|
||||
|
||||
it('should fetch from /history endpoint with default max_items', async () => {
|
||||
await fetchHistoryV1(mockFetchApi)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=200')
|
||||
})
|
||||
|
||||
it('should fetch with custom max_items parameter', async () => {
|
||||
await fetchHistoryV1(mockFetchApi, 50)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=50')
|
||||
})
|
||||
|
||||
it('should transform object response to array with taskType and preserve fields', async () => {
|
||||
const result = await fetchHistoryV1(mockFetchApi)
|
||||
|
||||
expect(result.History).toHaveLength(2)
|
||||
result.History.forEach((item) => {
|
||||
expect(item.taskType).toBe('History')
|
||||
})
|
||||
expect(result.History[0]).toMatchObject({
|
||||
taskType: 'History',
|
||||
prompt: [24, 'complete-item-id', {}, expect.any(Object), ['9']],
|
||||
outputs: expect.any(Object),
|
||||
status: expect.any(Object),
|
||||
meta: expect.any(Object)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty response object', async () => {
|
||||
const emptyMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({})
|
||||
})
|
||||
|
||||
const result = await fetchHistoryV1(emptyMock)
|
||||
|
||||
expect(result.History).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for V2 history fetcher.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchHistoryV2 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV2'
|
||||
|
||||
import {
|
||||
expectedV1Fixture,
|
||||
historyV2Fixture
|
||||
} from '@tests-ui/fixtures/historyFixtures'
|
||||
|
||||
describe('fetchHistoryV2', () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: async () => historyV2Fixture
|
||||
})
|
||||
|
||||
it('should fetch from /history_v2 endpoint with default max_items', async () => {
|
||||
await fetchHistoryV2(mockFetchApi)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200')
|
||||
})
|
||||
|
||||
it('should fetch with custom max_items parameter', async () => {
|
||||
await fetchHistoryV2(mockFetchApi, 50)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50')
|
||||
})
|
||||
|
||||
it('should adapt V2 response to V1-compatible format', async () => {
|
||||
const result = await fetchHistoryV2(mockFetchApi)
|
||||
|
||||
expect(result.History).toEqual(expectedV1Fixture)
|
||||
expect(result).toHaveProperty('History')
|
||||
expect(Array.isArray(result.History)).toBe(true)
|
||||
result.History.forEach((item) => {
|
||||
expect(item.taskType).toBe('History')
|
||||
expect(item.prompt).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,9 @@
|
||||
],
|
||||
"@/utils/networkUtil": [
|
||||
"packages/shared-frontend-utils/src/networkUtil.ts"
|
||||
],
|
||||
"@tests-ui/*": [
|
||||
"tests-ui/*"
|
||||
]
|
||||
},
|
||||
"typeRoots": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import dotenv from 'dotenv'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
@@ -13,7 +13,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
|
||||
|
||||
dotenv.config()
|
||||
dotenvConfig()
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
const SHOULD_MINIFY = process.env.ENABLE_MINIFY === 'true'
|
||||
@@ -302,7 +302,8 @@ export default defineConfig({
|
||||
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
|
||||
__DEV_SERVER_COMFYUI_URL__: JSON.stringify(DEV_SERVER_COMFYUI_URL)
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
'@/utils/formatUtil': '/packages/shared-frontend-utils/src/formatUtil.ts',
|
||||
'@/utils/networkUtil':
|
||||
'/packages/shared-frontend-utils/src/networkUtil.ts',
|
||||
'@tests-ui': '/tests-ui',
|
||||
'@': '/src'
|
||||
}
|
||||
},
|
||||
|
||||