mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
fix: desloppify code quality improvements (-353 lines)
- Fix empty catch blocks in app.ts, nodeTemplates.ts, uploadAudio.ts - Modernize legacy JS in app.ts copyToClipspace (var→const/let, .map()) - Simplify registrySearchGateway.ts from 224→92 lines (remove over-engineered circuit breaker) - Simplify auth.ts from 188→88 lines (extract fetchApiWithSentry helper) - Extract showWorkspaceDialog helper in dialogService.ts for 9 near-identical methods - Replace 8 repetitive boolean getters in queueStore.ts with lookup-table (-36 lines) - Remove redundant parameter-restating JSDoc from comfyRegistryService.ts - Remove stale comments and unused imports across multiple files Amp-Thread-ID: https://ampcode.com/threads/T-019cb19a-f2cf-760c-b8f1-cd43abdf7525 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -45,19 +45,19 @@ export const usdToCredits = (usd: number): number =>
|
||||
export const creditsToUsd = (credits: number): number =>
|
||||
Math.round((credits / CREDITS_PER_USD) * 100) / 100
|
||||
|
||||
export type FormatOptions = {
|
||||
type FormatOptions = {
|
||||
value: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromCentsOptions = {
|
||||
type FormatFromCentsOptions = {
|
||||
cents: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export type FormatFromUsdOptions = {
|
||||
type FormatFromUsdOptions = {
|
||||
usd: number
|
||||
locale?: string
|
||||
numberOptions?: Intl.NumberFormatOptions
|
||||
@@ -113,13 +113,3 @@ export const formatUsdFromCents = ({
|
||||
locale,
|
||||
numberOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* Clamps a USD value to the allowed range for credit purchases
|
||||
* @param value - The USD amount to clamp
|
||||
* @returns The clamped value between $1 and $1000, or 0 if NaN
|
||||
*/
|
||||
export const clampUsd = (value: number): number => {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(1000, Math.max(1, value))
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
export function parseProxyWidgets(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
let parsed = property
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Invalid assignment for properties.proxyWidgets:\nMalformed JSON string`
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = proxyWidgetsPropertySchema.safeParse(parsed)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
|
||||
export class ClipspaceDialog extends ComfyDialog {
|
||||
class ClipspaceDialog extends ComfyDialog {
|
||||
static items: Array<
|
||||
HTMLButtonElement & {
|
||||
contextPredicate?: () => boolean
|
||||
|
||||
@@ -100,7 +100,9 @@ class ManageTemplates extends ComfyDialog {
|
||||
if (res.status === 200) {
|
||||
try {
|
||||
templates = await res.json()
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// Invalid JSON in stored templates — fall through to return empty array
|
||||
}
|
||||
} else if (res.status !== 404) {
|
||||
console.error(res.status + ' ' + res.statusText)
|
||||
}
|
||||
|
||||
@@ -407,7 +407,9 @@ app.registerExtension({
|
||||
if (mediaRecorder) {
|
||||
try {
|
||||
mediaRecorder.stop()
|
||||
} catch {}
|
||||
} catch {
|
||||
// Recorder may already be stopped — safe to ignore
|
||||
}
|
||||
}
|
||||
useAudioService().stopAllTracks(currentStream)
|
||||
currentStream = null
|
||||
|
||||
@@ -9,118 +9,62 @@ interface UserCloudStatus {
|
||||
|
||||
const ONBOARDING_SURVEY_KEY = 'onboarding_survey'
|
||||
|
||||
/**
|
||||
* Helper function to capture API errors with Sentry
|
||||
*/
|
||||
function captureApiError(
|
||||
error: Error,
|
||||
async function fetchApiWithSentry(
|
||||
endpoint: string,
|
||||
errorType: 'http_error' | 'network_error',
|
||||
httpStatus?: number,
|
||||
operation?: string,
|
||||
extraContext?: Record<string, unknown>
|
||||
) {
|
||||
const tags: Record<string, string | number> = {
|
||||
api_endpoint: endpoint,
|
||||
error_type: errorType
|
||||
}
|
||||
|
||||
if (httpStatus !== undefined) {
|
||||
tags.http_status = httpStatus
|
||||
}
|
||||
|
||||
if (operation) {
|
||||
tags.operation = operation
|
||||
}
|
||||
|
||||
const sentryOptions: Sentry.ExclusiveEventHintOrCaptureContext = {
|
||||
tags,
|
||||
extra: extraContext ? { ...extraContext } : undefined
|
||||
}
|
||||
|
||||
Sentry.captureException(error, sentryOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if error is already handled HTTP error
|
||||
*/
|
||||
function isHttpError(error: unknown, errorMessagePrefix: string): boolean {
|
||||
return error instanceof Error && error.message.startsWith(errorMessagePrefix)
|
||||
}
|
||||
|
||||
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
|
||||
init: RequestInit,
|
||||
operation?: string
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const response = await api.fetchApi('/user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const response = await api.fetchApi(endpoint, init)
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Failed to get user: ${response.statusText}`)
|
||||
captureApiError(
|
||||
error,
|
||||
'/user',
|
||||
'http_error',
|
||||
response.status,
|
||||
undefined,
|
||||
{
|
||||
api: {
|
||||
method: 'GET',
|
||||
endpoint: '/user',
|
||||
status_code: response.status,
|
||||
status_text: response.statusText
|
||||
}
|
||||
}
|
||||
const error = new Error(
|
||||
`API ${init.method} ${endpoint} failed: ${response.statusText}`
|
||||
)
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_endpoint: endpoint,
|
||||
error_type: 'http_error',
|
||||
http_status: response.status,
|
||||
...(operation && { operation })
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to get user:')) {
|
||||
captureApiError(error as Error, '/user', 'network_error')
|
||||
if (!(error instanceof Error) || !error.message.startsWith('API ')) {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_endpoint: endpoint,
|
||||
error_type: 'network_error',
|
||||
...(operation && { operation })
|
||||
}
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
|
||||
const response = await fetchApiWithSentry('/user', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Not an error case - survey not completed is a valid state
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'auth',
|
||||
message: 'Survey status check returned non-ok response',
|
||||
level: 'info',
|
||||
data: {
|
||||
status: response.status,
|
||||
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!response.ok) return false
|
||||
const data = await response.json()
|
||||
// Check if data exists and is not empty
|
||||
return !isEmpty(data.value)
|
||||
} catch (error) {
|
||||
// Network error - still capture it as it's not thrown from above
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_endpoint: '/settings/{key}',
|
||||
error_type: 'network_error'
|
||||
},
|
||||
extra: {
|
||||
route_template: '/settings/{key}',
|
||||
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
},
|
||||
tags: { api_endpoint: '/settings/{key}', error_type: 'network_error' },
|
||||
level: 'warning'
|
||||
})
|
||||
return false
|
||||
@@ -130,59 +74,13 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
export async function submitSurvey(
|
||||
survey: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'auth',
|
||||
message: 'Submitting survey',
|
||||
level: 'info',
|
||||
data: {
|
||||
survey_fields: Object.keys(survey)
|
||||
}
|
||||
})
|
||||
|
||||
const response = await api.fetchApi('/settings', {
|
||||
await fetchApiWithSentry(
|
||||
'/settings',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Failed to submit survey: ${response.statusText}`)
|
||||
captureApiError(
|
||||
error,
|
||||
'/settings',
|
||||
'http_error',
|
||||
response.status,
|
||||
'submit_survey',
|
||||
{
|
||||
survey: {
|
||||
field_count: Object.keys(survey).length,
|
||||
field_names: Object.keys(survey)
|
||||
}
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Log successful survey submission
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'auth',
|
||||
message: 'Survey submitted successfully',
|
||||
level: 'info'
|
||||
})
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to submit survey:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
'/settings',
|
||||
'network_error',
|
||||
undefined,
|
||||
'submit_survey'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
'submit_survey'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,9 +153,7 @@ export async function extractWorkflow(
|
||||
const rawWorkflow = parsed.data.extra_data?.extra_pnginfo?.workflow
|
||||
if (!rawWorkflow) return undefined
|
||||
|
||||
const validated = await validateComfyWorkflow(rawWorkflow, (error) => {
|
||||
console.warn('[extractWorkflow] Workflow validation failed:', error)
|
||||
})
|
||||
const validated = await validateComfyWorkflow(rawWorkflow)
|
||||
|
||||
return validated ?? undefined
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ const zBaseExportableGraph = z.object({
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
@@ -353,7 +353,7 @@ interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
|
||||
@@ -382,32 +382,26 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
static copyToClipspace(node: LGraphNode) {
|
||||
var widgets = null
|
||||
if (node.widgets) {
|
||||
widgets = node.widgets.map(({ type, name, value }) => ({
|
||||
type,
|
||||
name,
|
||||
value
|
||||
}))
|
||||
}
|
||||
const widgets = node.widgets
|
||||
? node.widgets.map(({ type, name, value }) => ({
|
||||
type,
|
||||
name,
|
||||
value
|
||||
}))
|
||||
: null
|
||||
|
||||
var imgs = undefined
|
||||
var orig_imgs = undefined
|
||||
let imgs: HTMLImageElement[] | undefined
|
||||
let orig_imgs: HTMLImageElement[] | undefined
|
||||
if (node.imgs != undefined) {
|
||||
imgs = []
|
||||
orig_imgs = []
|
||||
|
||||
for (let i = 0; i < node.imgs.length; i++) {
|
||||
imgs[i] = new Image()
|
||||
imgs[i].src = node.imgs[i].src
|
||||
orig_imgs[i] = imgs[i]
|
||||
}
|
||||
imgs = node.imgs.map((img) => {
|
||||
const copy = new Image()
|
||||
copy.src = img.src
|
||||
return copy
|
||||
})
|
||||
orig_imgs = [...imgs]
|
||||
}
|
||||
|
||||
var selectedIndex = 0
|
||||
if (node.imageIndex) {
|
||||
selectedIndex = node.imageIndex
|
||||
}
|
||||
const selectedIndex = node.imageIndex ?? 0
|
||||
|
||||
const paintedIndex = imgs ? imgs.length + 1 : 1
|
||||
const combinedIndex = imgs ? imgs.length + 2 : 2
|
||||
@@ -1518,7 +1512,9 @@ export class ComfyApp {
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
console.warn('Failed to store execution job:', error)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
@@ -64,13 +64,6 @@ export const useComfyRegistryService = () => {
|
||||
return `${context}: ${axiosError.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an API request with error and loading state handling
|
||||
* @param apiCall - Function that returns a promise with the API call
|
||||
* @param errorContext - Context description for error messages
|
||||
* @param routeSpecificErrors - Optional map of status codes to custom error messages
|
||||
* @returns Promise with the API response data or null if the request failed
|
||||
*/
|
||||
const executeApiRequest = async <T>(
|
||||
apiCall: () => Promise<AxiosResponse<T>>,
|
||||
errorContext: string,
|
||||
@@ -93,12 +86,6 @@ export const useComfyRegistryService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Comfy Node definitions in a specific version of a node pack
|
||||
* @param packId - The ID of the node pack
|
||||
* @param versionId - The version of the node pack
|
||||
* @returns The node definitions or null if not found or an error occurred
|
||||
*/
|
||||
const getNodeDefs = async (
|
||||
params: {
|
||||
packId: components['schemas']['Node']['id']
|
||||
@@ -360,29 +347,6 @@ export const useComfyRegistryService = () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple pack versions in a single bulk request.
|
||||
* This is more efficient than making individual requests for each pack version.
|
||||
*
|
||||
* @param nodeVersions - Array of node ID and version pairs to retrieve
|
||||
* @param signal - Optional AbortSignal for request cancellation
|
||||
* @returns Bulk response containing the requested node versions or null on error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const versions = await getBulkNodeVersions([
|
||||
* { node_id: 'ComfyUI-Manager', version: '1.0.0' },
|
||||
* { node_id: 'ComfyUI-Impact-Pack', version: '2.0.0' }
|
||||
* ])
|
||||
* if (versions) {
|
||||
* versions.node_versions.forEach(result => {
|
||||
* if (result.status === 'success' && result.node_version) {
|
||||
* console.log(`Retrieved ${result.identifier.node_id}@${result.identifier.version}`)
|
||||
* }
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
const getBulkNodeVersions = async (
|
||||
nodeVersions: components['schemas']['NodeVersionIdentifier'][],
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -423,101 +423,91 @@ export const useDialogService = () => {
|
||||
}
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
async function showWorkspaceDialog(
|
||||
key: string,
|
||||
loader: () => Promise<{ default: Component }>,
|
||||
props?: Record<string, unknown>
|
||||
) {
|
||||
const { default: component } = await loader()
|
||||
return dialogStore.showDialog({
|
||||
key,
|
||||
component,
|
||||
props,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
function showDeleteWorkspaceDialog(options?: {
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/DeleteWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
return showWorkspaceDialog(
|
||||
'delete-workspace',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/DeleteWorkspaceDialogContent.vue'),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
async function showCreateWorkspaceDialog(
|
||||
function showCreateWorkspaceDialog(
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/CreateWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'create-workspace',
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
return showWorkspaceDialog(
|
||||
'create-workspace',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/CreateWorkspaceDialogContent.vue'),
|
||||
{ onConfirm }
|
||||
)
|
||||
}
|
||||
|
||||
async function showLeaveWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/LeaveWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
function showLeaveWorkspaceDialog() {
|
||||
return showWorkspaceDialog(
|
||||
'leave-workspace',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/LeaveWorkspaceDialogContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
async function showEditWorkspaceDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/EditWorkspaceDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
function showEditWorkspaceDialog() {
|
||||
return showWorkspaceDialog(
|
||||
'edit-workspace',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/EditWorkspaceDialogContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
async function showRemoveMemberDialog(memberId: string) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RemoveMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
function showRemoveMemberDialog(memberId: string) {
|
||||
return showWorkspaceDialog(
|
||||
'remove-member',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/RemoveMemberDialogContent.vue'),
|
||||
{ memberId }
|
||||
)
|
||||
}
|
||||
|
||||
async function showInviteMemberDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/InviteMemberDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
function showInviteMemberDialog() {
|
||||
return showWorkspaceDialog(
|
||||
'invite-member',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/InviteMemberDialogContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
async function showInviteMemberUpsellDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
function showInviteMemberUpsellDialog() {
|
||||
return showWorkspaceDialog(
|
||||
'invite-member-upsell',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
function showRevokeInviteDialog(inviteId: string) {
|
||||
return showWorkspaceDialog(
|
||||
'revoke-invite',
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue'),
|
||||
{ inviteId }
|
||||
)
|
||||
}
|
||||
|
||||
function showBillingComingSoonDialog() {
|
||||
@@ -538,17 +528,13 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showCancelSubscriptionDialog(cancelAt?: string) {
|
||||
const { default: component } =
|
||||
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'cancel-subscription',
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
function showCancelSubscriptionDialog(cancelAt?: string) {
|
||||
return showWorkspaceDialog(
|
||||
'cancel-subscription',
|
||||
() =>
|
||||
import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue'),
|
||||
{ cancelAt }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,39 +9,23 @@ import type {
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
interface ProviderState {
|
||||
interface NamedProvider {
|
||||
provider: NodePackSearchProvider
|
||||
name: string
|
||||
isHealthy: boolean
|
||||
lastError?: Error
|
||||
lastAttempt?: Date
|
||||
consecutiveFailures: number
|
||||
}
|
||||
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
|
||||
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
|
||||
|
||||
/**
|
||||
* API Gateway for registry search providers with circuit breaker pattern.
|
||||
* Acts as a single entry point that routes search requests to appropriate providers
|
||||
* and handles failures gracefully by falling back to alternative providers.
|
||||
*
|
||||
* Implements:
|
||||
* - Gateway pattern: Single entry point for all search requests
|
||||
* - Circuit breaker: Prevents repeated calls to failed services
|
||||
* - Automatic failover: Cascades through providers on failure
|
||||
* Search gateway with primary/fallback provider pattern.
|
||||
* Tries Algolia first, falls back to ComfyRegistry on failure.
|
||||
*/
|
||||
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
const providers: ProviderState[] = []
|
||||
const providers: NamedProvider[] = []
|
||||
let activeProviderIndex = 0
|
||||
|
||||
// Initialize providers in priority order
|
||||
try {
|
||||
providers.push({
|
||||
provider: useAlgoliaSearchProvider(),
|
||||
name: 'Algolia',
|
||||
isHealthy: true,
|
||||
consecutiveFailures: 0
|
||||
name: 'Algolia'
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize Algolia provider:', error)
|
||||
@@ -49,170 +33,50 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
|
||||
providers.push({
|
||||
provider: useComfyRegistrySearchProvider(),
|
||||
name: 'ComfyRegistry',
|
||||
isHealthy: true,
|
||||
consecutiveFailures: 0
|
||||
name: 'ComfyRegistry'
|
||||
})
|
||||
|
||||
// TODO: Add an "offline" provider that operates on a local cache of the registry.
|
||||
|
||||
/**
|
||||
* Check if a provider's circuit breaker should be closed (available to try)
|
||||
*/
|
||||
const isCircuitClosed = (providerState: ProviderState): boolean => {
|
||||
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if enough time has passed to retry
|
||||
if (providerState.lastAttempt) {
|
||||
const timeSinceLastAttempt =
|
||||
Date.now() - providerState.lastAttempt.getTime()
|
||||
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful call to a provider
|
||||
*/
|
||||
const recordSuccess = (providerState: ProviderState) => {
|
||||
providerState.isHealthy = true
|
||||
providerState.consecutiveFailures = 0
|
||||
providerState.lastError = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed call to a provider
|
||||
*/
|
||||
const recordFailure = (providerState: ProviderState, error: Error) => {
|
||||
providerState.consecutiveFailures++
|
||||
providerState.lastError = error
|
||||
providerState.lastAttempt = new Date()
|
||||
|
||||
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||
providerState.isHealthy = false
|
||||
console.warn(
|
||||
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active provider based on circuit breaker states
|
||||
*/
|
||||
const getActiveProvider = (): NodePackSearchProvider => {
|
||||
// First, try to use the current active provider if it's healthy
|
||||
const currentProvider = providers[activeProviderIndex]
|
||||
if (currentProvider && isCircuitClosed(currentProvider)) {
|
||||
return currentProvider.provider
|
||||
}
|
||||
|
||||
// Otherwise, find the first healthy provider
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const providerState = providers[i]
|
||||
if (isCircuitClosed(providerState)) {
|
||||
activeProviderIndex = i
|
||||
return providerState.provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No available search providers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the active provider index after a failure.
|
||||
* Move to the next provider if available.
|
||||
*/
|
||||
const updateActiveProviderOnFailure = () => {
|
||||
if (activeProviderIndex < providers.length - 1) {
|
||||
activeProviderIndex++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node packs.
|
||||
*/
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
let lastError: Error | null = null
|
||||
|
||||
// Start with the current active provider
|
||||
for (let attempts = 0; attempts < providers.length; attempts++) {
|
||||
for (let i = activeProviderIndex; i < providers.length; i++) {
|
||||
try {
|
||||
const provider = getActiveProvider()
|
||||
const providerState = providers[activeProviderIndex]
|
||||
|
||||
const result = await provider.searchPacks(query, params)
|
||||
recordSuccess(providerState)
|
||||
const result = await providers[i].provider.searchPacks(query, params)
|
||||
activeProviderIndex = i
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
const providerState = providers[activeProviderIndex]
|
||||
recordFailure(providerState, lastError)
|
||||
console.warn(
|
||||
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
|
||||
error
|
||||
)
|
||||
|
||||
// Try the next provider
|
||||
updateActiveProviderOnFailure()
|
||||
console.warn(`${providers[i].name} search failed:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all providers failed
|
||||
throw new Error(
|
||||
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
|
||||
`All search providers failed. Last error: ${lastError?.message ?? 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search cache for all providers that implement it.
|
||||
*/
|
||||
const clearSearchCache = () => {
|
||||
for (const providerState of providers) {
|
||||
for (const { provider, name } of providers) {
|
||||
try {
|
||||
providerState.provider.clearSearchCache()
|
||||
provider.clearSearchCache()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to clear cache for ${providerState.name} provider:`,
|
||||
error
|
||||
)
|
||||
console.warn(`Failed to clear cache for ${name} provider:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort value for a pack.
|
||||
* @example
|
||||
* const pack = {
|
||||
* id: '123',
|
||||
* name: 'Test Pack',
|
||||
* downloads: 100
|
||||
* }
|
||||
* const sortValue = getSortValue(pack, 'downloads')
|
||||
* console.log(sortValue) // 100
|
||||
*/
|
||||
const getSortValue = (
|
||||
pack: RegistryNodePack,
|
||||
sortField: string
|
||||
): string | number => {
|
||||
return getActiveProvider().getSortValue(pack, sortField)
|
||||
return providers[activeProviderIndex].provider.getSortValue(pack, sortField)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sortable fields for the active provider.
|
||||
* @example
|
||||
* const sortableFields = getSortableFields()
|
||||
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
|
||||
*/
|
||||
const getSortableFields = () => {
|
||||
return getActiveProvider().getSortableFields()
|
||||
return providers[activeProviderIndex].provider.getSortableFields()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -82,7 +82,7 @@ interface Load3DNode extends LGraphNode {
|
||||
|
||||
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
|
||||
|
||||
export class Load3dService {
|
||||
class Load3dService {
|
||||
private static instance: Load3dService
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -99,83 +99,47 @@ export class ResultItemImpl {
|
||||
return !!this.format && !!this.frame_rate
|
||||
}
|
||||
|
||||
get extension(): string {
|
||||
const dotIndex = this.filename.lastIndexOf('.')
|
||||
return dotIndex >= 0 ? this.filename.slice(dotIndex + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
get htmlVideoType(): string | undefined {
|
||||
if (this.isWebm) {
|
||||
return 'video/webm'
|
||||
}
|
||||
if (this.isMp4) {
|
||||
return 'video/mp4'
|
||||
const videoMimeTypes: Record<string, string> = {
|
||||
webm: 'video/webm',
|
||||
mp4: 'video/mp4'
|
||||
}
|
||||
const byExtension = videoMimeTypes[this.extension]
|
||||
if (byExtension) return byExtension
|
||||
|
||||
if (this.isVhsFormat) {
|
||||
if (this.format?.endsWith('webm')) {
|
||||
return 'video/webm'
|
||||
}
|
||||
if (this.format?.endsWith('mp4')) {
|
||||
return 'video/mp4'
|
||||
for (const [ext, mime] of Object.entries(videoMimeTypes)) {
|
||||
if (this.format?.endsWith(ext)) return mime
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get htmlAudioType(): string | undefined {
|
||||
if (this.isMp3) {
|
||||
return 'audio/mpeg'
|
||||
const audioMimeTypes: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac'
|
||||
}
|
||||
if (this.isWav) {
|
||||
return 'audio/wav'
|
||||
}
|
||||
if (this.isOgg) {
|
||||
return 'audio/ogg'
|
||||
}
|
||||
if (this.isFlac) {
|
||||
return 'audio/flac'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get isGif(): boolean {
|
||||
return this.filename.endsWith('.gif')
|
||||
}
|
||||
|
||||
get isWebp(): boolean {
|
||||
return this.filename.endsWith('.webp')
|
||||
}
|
||||
|
||||
get isWebm(): boolean {
|
||||
return this.filename.endsWith('.webm')
|
||||
}
|
||||
|
||||
get isMp4(): boolean {
|
||||
return this.filename.endsWith('.mp4')
|
||||
return audioMimeTypes[this.extension]
|
||||
}
|
||||
|
||||
get isVideoBySuffix(): boolean {
|
||||
return this.isWebm || this.isMp4
|
||||
return this.extension === 'webm' || this.extension === 'mp4'
|
||||
}
|
||||
|
||||
get isImageBySuffix(): boolean {
|
||||
return this.isGif || this.isWebp
|
||||
}
|
||||
|
||||
get isMp3(): boolean {
|
||||
return this.filename.endsWith('.mp3')
|
||||
}
|
||||
|
||||
get isWav(): boolean {
|
||||
return this.filename.endsWith('.wav')
|
||||
}
|
||||
|
||||
get isOgg(): boolean {
|
||||
return this.filename.endsWith('.ogg')
|
||||
}
|
||||
|
||||
get isFlac(): boolean {
|
||||
return this.filename.endsWith('.flac')
|
||||
return this.extension === 'gif' || this.extension === 'webp'
|
||||
}
|
||||
|
||||
get isAudioBySuffix(): boolean {
|
||||
return this.isMp3 || this.isWav || this.isOgg || this.isFlac
|
||||
return ['mp3', 'wav', 'ogg', 'flac'].includes(this.extension)
|
||||
}
|
||||
|
||||
get isVideo(): boolean {
|
||||
@@ -631,11 +595,7 @@ export const useQueuePendingTaskCountStore = defineStore(
|
||||
}
|
||||
)
|
||||
|
||||
export type AutoQueueMode =
|
||||
| 'disabled'
|
||||
| 'change'
|
||||
| 'instant-idle'
|
||||
| 'instant-running'
|
||||
type AutoQueueMode = 'disabled' | 'change' | 'instant-idle' | 'instant-running'
|
||||
|
||||
export const isInstantMode = (
|
||||
mode: AutoQueueMode
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
* Used by desktop-ui app storybook stories
|
||||
* @public
|
||||
*/
|
||||
export type ElectronWindow = typeof window & {
|
||||
type ElectronWindow = typeof window & {
|
||||
electronAPI?: ElectronAPI
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const MEDIA_SRC_REGEX =
|
||||
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
|
||||
|
||||
// Create a marked Renderer that prefixes relative URLs with base
|
||||
export function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
|
||||
const renderer = new Renderer()
|
||||
renderer.image = ({ href, title, text }) => {
|
||||
|
||||
Reference in New Issue
Block a user