Merge remote-tracking branch 'origin/main' into bl-teenage-planarian

This commit is contained in:
Benjamin Lu
2025-12-29 12:19:29 -08:00
71 changed files with 3026 additions and 999 deletions

View File

@@ -22,29 +22,38 @@
state-storage="local"
@resizestart="onResizestart"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="sidebarLocation === 'left' && !focusMode"
:class="
cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
"
:min-size="10"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'left'
? 'flex'
: 'none'
}"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'left'"
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
@@ -73,38 +82,33 @@
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="sidebarLocation === 'right' && !focusMode"
:class="
cn(
'side-bar-panel pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
"
:min-size="10"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'right'
? 'flex'
: 'none'
}"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'right'"
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
<!-- Right Side Panel - independent of sidebar -->
<SplitterPanel
v-if="rightSidePanelVisible && !focusMode"
class="bg-comfy-menu-bg pointer-events-auto"
:min-size="15"
:size="20"
>
<slot name="right-side-panel" />
</SplitterPanel>
</Splitter>
</div>
</div>
@@ -117,6 +121,7 @@ import Splitter from 'primevue/splitter'
import type { SplitterResizeStartEvent } from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -128,6 +133,7 @@ const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const { t } = useI18n()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -159,12 +165,25 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
}
/*
* Force refresh the splitter when right panel visibility changes to recalculate the width
* Force refresh the splitter when right panel visibility or sidebar location changes
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return rightSidePanelVisible.value
? 'main-splitter-with-right-panel'
: 'main-splitter'
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const lastPanelStyle = computed(() => {
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
</script>

View File

@@ -197,7 +197,16 @@ const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const promptId = item.taskRef?.promptId
if (!promptId) return
await api.interrupt(promptId)
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
await api.interrupt(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', promptId)
await queueStore.update()
}
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {

View File

@@ -9,6 +9,7 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -22,11 +23,23 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { selectedItems } = storeToRefs(canvasStore)
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
// Panel is on the left when sidebar is on the right, and vice versa
const panelIcon = computed(() =>
sidebarLocation.value === 'right'
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-right]'
)
const hasSelection = computed(() => selectedItems.value.length > 0)
const selectedNodes = computed((): LGraphNode[] => {
@@ -160,7 +173,7 @@ function handleTitleCancel() {
:aria-label="t('rightSidePanel.togglePanel')"
@click="closePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
<i :class="cn(panelIcon, 'size-4')" />
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -71,7 +72,7 @@ const displayLabel = computed(
<component
:is="getWidgetComponent(widget)"
:widget="widget"
:model-value="widget.value"
:model-value="useReactiveWidgetValue(widget)"
:node-id="String(node.id)"
:node-type="node.type"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"

View File

@@ -11,6 +11,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -102,8 +103,11 @@ export const useFirebaseAuthActions = () => {
window.open(response.checkout_url, '_blank')
}, reportError)
const accessBillingPortal = wrapWithErrorHandlingAsync(async () => {
const response = await authStore.accessBillingPortal()
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier],
void
>(async (targetTier) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
throw new Error(
t('toastMessages.failedToAccessBillingPortal', {

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { customRef, reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
@@ -90,6 +90,23 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
export function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -106,6 +123,37 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
return subNode?.type
}
/**
* Validates that a value is a valid WidgetValue type
*/
const normalizeWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
@@ -113,19 +161,6 @@ export function safeWidgetMapper(
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
const borderStyle = widget.promoted
@@ -133,13 +168,18 @@ export function safeWidgetMapper(
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
widget.callback?.(value)
}
return {
name: widget.name,
type: widget.type,
value: value,
value: useReactiveWidgetValue(widget),
borderStyle,
callback: widget.callback,
callback,
controlWidget: getControlWidget(widget),
isDOMWidget: isDOMWidget(widget),
label: widget.label,
@@ -286,128 +326,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return nodeRefs.get(id)
}
/**
* Validates that a value is a valid WidgetValue type
*/
const validateWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Updates Vue state when widget values change
*/
const updateVueWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
try {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
...currentData,
widgets: updatedWidgets
}
vueNodeData.set(nodeId, updatedData)
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
}
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedWidgetCallback = (
widget: IBaseWidget, // LiteGraph widget with minimal typing
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value ?? undefined
// 2. Call the original callback if it exists
if (originalCallback && widget.type !== 'asset') {
originalCallback.call(widget, value)
}
// 3. Update Vue state to maintain synchronization
updateVueWidgetState(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedWidgetCallback(
widget,
originalCallback,
nodeId
)
})
}
const syncWithGraph = () => {
if (!graph?._nodes) return
@@ -428,9 +346,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
@@ -449,9 +364,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))

View File

@@ -116,6 +116,31 @@ const makeOmniProDurationCalculator =
return formatCreditsLabel(cost)
}
const klingMotionControlPricingCalculator: PricingFunction = (
node: LGraphNode
): string => {
const modeWidget = node.widgets?.find(
(w) => w.name === 'mode'
) as IComboWidget
if (!modeWidget) {
return formatCreditsListLabel([0.07, 0.112], {
suffix: '/second',
note: '(std/pro)'
})
}
const mode = String(modeWidget.value).toLowerCase()
if (mode === 'pro') return formatCreditsLabel(0.112, { suffix: '/second' })
if (mode === 'std') return formatCreditsLabel(0.07, { suffix: '/second' })
return formatCreditsListLabel([0.07, 0.112], {
suffix: '/second',
note: '(std/pro)'
})
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -1034,6 +1059,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingOmniProVideoToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.168)
},
KlingMotionControl: {
displayPrice: klingMotionControlPricingCalculator
},
KlingOmniProEditVideoNode: {
displayPrice: formatCreditsLabel(0.168, { suffix: '/second' })
},
@@ -2117,6 +2145,7 @@ export const useNodePricing = () => {
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],
KlingOmniProVideoToVideoNode: ['duration'],
KlingMotionControl: ['mode'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],

View File

@@ -13,7 +13,8 @@ export enum ServerFeatureFlag {
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled'
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
}
/**
@@ -62,6 +63,16 @@ export function useFeatureFlags() {
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
)
},
get huggingfaceModelImportEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.huggingface_model_import_enabled ??
api.getServerFeature(
ServerFeatureFlag.HUGGINGFACE_MODEL_IMPORT_ENABLED,
false
)
)
}
})

View File

@@ -211,11 +211,9 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown, receiver: object) {
set(_t: IBaseWidget, property: string, value: unknown) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'computedHeight') {
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {
updatePreviews(linkedNode)
}
@@ -228,9 +226,8 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
}
return Reflect.set(redirectedTarget, property, value, redirectedReceiver)
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
},
getPrototypeOf() {
return Reflect.getPrototypeOf(backingWidget)

View File

@@ -0,0 +1,48 @@
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCompare') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
const onExecuted = node.onExecuted
node.onExecuted = function (output: NodeExecutionOutput) {
onExecuted?.call(this, output)
const aImages = (output as Record<string, unknown>).a_images as
| Record<string, string>[]
| undefined
const bImages = (output as Record<string, unknown>).b_images as
| Record<string, string>[]
| undefined
const rand = app.getRandParam()
const beforeUrl =
aImages && aImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
: ''
const afterUrl =
bImages && bImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
: ''
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = {
before: beforeUrl,
after: afterUrl
}
widget.callback?.(widget.value)
}
}
}
})

View File

@@ -9,6 +9,7 @@ import './electronAdapter'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './load3d'
import './maskeditor'
import './nodeTemplates'

View File

@@ -192,8 +192,14 @@ export class DragAndScale {
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
): void {
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
//If element hasn't initialized (browser tab is in background)
//it has a size of 300x150 and a more reasonable default is used instead.
const [width, height] =
this.element.width === 300 && this.element.height === 150
? [1920, 1080]
: [this.element.width, this.element.height]
const cw = width / window.devicePixelRatio
const ch = height / window.devicePixelRatio
let targetScale = this.scale
if (zoom > 0) {

View File

@@ -284,6 +284,7 @@ export interface IBaseWidget<
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
vueTrack?: () => void
/**
* Whether the widget value should be serialized on node serialization.

View File

@@ -648,6 +648,7 @@
"box": "Box"
},
"sideToolbar": {
"sidebar": "Sidebar",
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
"logout": "Logout",
@@ -1416,6 +1417,7 @@
"latent": "latent",
"3d": "3d",
"ltxv": "ltxv",
"qwen": "qwen",
"sd3": "sd3",
"unet": "unet",
"sigmas": "sigmas",
@@ -1447,7 +1449,6 @@
"photomaker": "photomaker",
"PixVerse": "PixVerse",
"primitive": "primitive",
"qwen": "qwen",
"Recraft": "Recraft",
"edit_models": "edit_models",
"Rodin": "Rodin",
@@ -1607,6 +1608,9 @@
"errorMessage": "Failed to copy to clipboard",
"errorNotSupported": "Clipboard API not supported in your browser"
},
"imageCompare": {
"noImages": "No images to compare"
},
"load3d": {
"switchCamera": "Switch Camera",
"showGrid": "Show Grid",
@@ -2233,8 +2237,11 @@
"baseModels": "Base models",
"browseAssets": "Browse Assets",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Example:",
"civitaiLinkExampleUrl": "https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295",
"civitaiLinkLabel": "Civitai model {download} link",
"civitaiLinkLabelDownload": "download",
"civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again",
@@ -2252,8 +2259,11 @@
"filterBy": "Filter by",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"genericLinkPlaceholder": "Paste link here",
"jobId": "Job ID",
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -2268,20 +2278,24 @@
"ownershipAll": "All",
"ownershipMyModels": "My models",
"ownershipPublicModels": "Public models",
"providerCivitai": "Civitai",
"providerHuggingFace": "Hugging Face",
"noValidSourceDetected": "No valid import source detected",
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
"sortAZ": "A-Z",
"sortBy": "Sort by",
"sortingType": "Sorting Type",
"sortPopular": "Popular",
"sortRecent": "Recent",
"sortZA": "Z-A",
"sortingType": "Sorting Type",
"tags": "Tags",
"tagsHelp": "Separate tags with commas",
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
@@ -2289,10 +2303,15 @@
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"uploadModelDescription1Generic": "Paste a model download link to add it to your library.",
"uploadModelDescription2": "Only links from {link} are supported at the moment",
"uploadModelDescription2Link": "https://civitai.com/models",
"uploadModelDescription2Generic": "Only URLs from the following providers are supported:",
"uploadModelDescription3": "Max file size: {size}",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelGeneric": "Import a model",
"uploadModelHelpFooterText": "Need help finding the URLs? Click on a provider below to see a how-to video.",
"uploadModelHelpVideo": "Upload Model Help Video",
"uploadModelHowDoIFindThis": "How do I find this?",
"uploadSuccess": "Model imported successfully!",
@@ -2428,4 +2447,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -2359,6 +2359,28 @@
}
}
},
"EmptyQwenImageLayeredLatentImage": {
"display_name": "Empty Qwen Image Layered Latent",
"inputs": {
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"layers": {
"name": "layers"
},
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptySD3LatentImage": {
"display_name": "EmptySD3LatentImage",
"inputs": {
@@ -3161,13 +3183,16 @@
},
"outputs": {
"0": {
"name": "width"
"name": "width",
"tooltip": null
},
"1": {
"name": "height"
"name": "height",
"tooltip": null
},
"2": {
"name": "batch_size"
"name": "batch_size",
"tooltip": null
}
}
},
@@ -3703,6 +3728,11 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageBatch": {
@@ -3819,6 +3849,11 @@
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
@@ -3849,6 +3884,11 @@
"flip_method": {
"name": "flip_method"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageFromBatch": {
@@ -3863,6 +3903,11 @@
"length": {
"name": "length"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageGrid": {
@@ -4002,6 +4047,11 @@
"rotation": {
"name": "rotation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageScale": {
@@ -4050,6 +4100,11 @@
"largest_size": {
"name": "largest_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageScaleToTotalPixels": {
@@ -4098,7 +4153,7 @@
},
"ImageStitch": {
"display_name": "Image Stitch",
"description": "\nStitches image2 to image1 in the specified direction.\nIf image2 is not provided, returns image1 unchanged.\nOptional spacing can be added between images.\n",
"description": "Stitches image2 to image1 in the specified direction.\nIf image2 is not provided, returns image1 unchanged.\nOptional spacing can be added between images.",
"inputs": {
"image1": {
"name": "image1"
@@ -4118,6 +4173,11 @@
"image2": {
"name": "image2"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageToMask": {
@@ -4665,6 +4725,36 @@
}
}
},
"KlingMotionControl": {
"display_name": "Kling Motion Control",
"inputs": {
"prompt": {
"name": "prompt"
},
"reference_image": {
"name": "reference_image"
},
"reference_video": {
"name": "reference_video",
"tooltip": "Motion reference video used to drive movement/expression.\nDuration limits depend on character_orientation:\n - image: 310s (max 10s)\n - video: 330s (max 30s)"
},
"keep_original_sound": {
"name": "keep_original_sound"
},
"character_orientation": {
"name": "character_orientation",
"tooltip": "Controls where the character's facing/orientation comes from.\nvideo: movements, expressions, camera moves, and orientation follow the motion reference video (other details via prompt).\nimage: movements and expressions still follow the motion reference video, but the character orientation matches the reference image (camera/other details via prompt)."
},
"mode": {
"name": "mode"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingOmniProEditVideoNode": {
"display_name": "Kling Omni Edit Video (Pro)",
"description": "Edit an existing video with the latest model from Kling.",
@@ -6413,6 +6503,19 @@
}
}
},
"ManualSigmas": {
"display_name": "ManualSigmas",
"inputs": {
"sigmas": {
"name": "sigmas"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MaskComposite": {
"display_name": "MaskComposite",
"inputs": {
@@ -10430,6 +10533,11 @@
"amount": {
"name": "amount"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RepeatLatentBatch": {
@@ -10517,6 +10625,11 @@
"interpolation": {
"name": "interpolation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ResizeImagesByLongerEdge": {
@@ -13816,7 +13929,7 @@
},
"enhance_prompt": {
"name": "enhance_prompt",
"tooltip": "Whether to enhance the prompt with AI assistance"
"tooltip": "This parameter is deprecated and ignored."
},
"person_generation": {
"name": "person_generation",

View File

@@ -460,4 +460,4 @@
"pysssss_SnapToGrid": {
"name": "Always snap to grid"
}
}
}

View File

@@ -1,4 +1,40 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "检查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "打开自定义节点文件夹"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "打开输入文件夹"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "打开日志文件夹"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "打开 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "打开模型文件夹"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "打开输出文件夹"
},
"Comfy-Desktop_OpenDevTools": {
"label": "打开开发者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面端用户手册"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重装"
},
"Comfy-Desktop_Restart": {
"label": "重启"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "为所选节点开启 3D 浏览器Beta 版)"
},
@@ -221,6 +257,9 @@
"Comfy_ToggleHelpCenter": {
"label": "说明中心"
},
"Comfy_ToggleLinear": {
"label": "切换线性模式"
},
"Comfy_ToggleTheme": {
"label": "切换主题"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,9 +57,6 @@
"Comfy_ConfirmClear": {
"name": "清除工作流时需要确认"
},
"Comfy_DOMClippingEnabled": {
"name": "启用DOM元素裁剪启用可能会降低性能"
},
"Comfy_DevMode": {
"name": "启用开发模式选项API保存等"
},
@@ -70,6 +67,9 @@
"Comfy_DisableSliders": {
"name": "禁用节点组件滑块"
},
"Comfy_DOMClippingEnabled": {
"name": "启用DOM元素裁剪启用可能会降低性能"
},
"Comfy_EditAttention_Delta": {
"name": "Ctrl+上/下 精度"
},
@@ -79,6 +79,17 @@
"Comfy_EnableWorkflowViewRestore": {
"name": "在工作流中保存和恢复视图位置及缩放"
},
"Comfy_Execution_PreviewMethod": {
"name": "实时预览",
"options": {
"auto": "自动",
"default": "默认",
"latent2rgb": "latent2rgb",
"none": "无",
"taesd": "taesd"
},
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。"
},
"Comfy_FloatRoundingPrecision": {
"name": "浮点组件四舍五入的小数位数 [0 = 自动]。",
"tooltip": "(需要重新加载页面)"
@@ -100,15 +111,19 @@
"None": "无"
}
},
"Comfy_Graph_LiveSelection": {
"name": "实时选择",
"tooltip": "启用后,在框选拖动时实时选择/取消选择节点,类似于其他设计工具。"
},
"Comfy_Graph_ZoomSpeed": {
"name": "画布缩放速度"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "选定节点的组内边距"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "双击组标题以编辑"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "选定节点的组内边距"
},
"Comfy_LinkRelease_Action": {
"name": "释放连线时的操作",
"options": {
@@ -166,6 +181,15 @@
"name": "光照强度下限",
"tooltip": "设置3D场景允许的最小光照强度值。此项定义在调整任何3D控件照明时可设定的最低亮度。"
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY 引擎",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。"
},
"Comfy_Load3D_ShowGrid": {
"name": "显示网格",
"tooltip": "默认显示网格开关"
@@ -193,6 +217,38 @@
},
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "连线自动吸附到节点接口",
"tooltip": "在节点上拖动连线时,连线会自动吸附到节点的可用输入接口。"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "删除节点时保留连线",
"tooltip": "删除节点时,尝试重新连接其所有输入和输出连线(类似于忽略节点)。"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "双击节点标题以编辑"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "中键单击创建新的转接点"
},
"Comfy_Node_Opacity": {
"name": "节点不透明度"
},
"Comfy_Node_ShowDeprecated": {
"name": "在搜索中显示已弃用的节点",
"tooltip": "弃用节点在UI中默认隐藏但在工作流中仍然有效。"
},
"Comfy_Node_ShowExperimental": {
"name": "在搜索中显示实验性节点",
"tooltip": "实验节点在UI中标记为实验性可能在未来版本中发生重大变化或被移除。在生产工作流中谨慎使用。"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "吸附高亮节点",
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "节点ID标签",
"options": {
@@ -245,38 +301,6 @@
"name": "节点建议数量",
"tooltip": "仅适用于 litegraph"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "连线自动吸附到节点接口",
"tooltip": "在节点上拖动连线时,连线会自动吸附到节点的可用输入接口。"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "删除节点时保留连线",
"tooltip": "删除节点时,尝试重新连接其所有输入和输出连线(类似于忽略节点)。"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "双击节点标题以编辑"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "中键单击创建新的转接点"
},
"Comfy_Node_Opacity": {
"name": "节点不透明度"
},
"Comfy_Node_ShowDeprecated": {
"name": "在搜索中显示已弃用的节点",
"tooltip": "弃用节点在UI中默认隐藏但在工作流中仍然有效。"
},
"Comfy_Node_ShowExperimental": {
"name": "在搜索中显示实验性节点",
"tooltip": "实验节点在UI中标记为实验性可能在未来版本中发生重大变化或被移除。在生产工作流中谨慎使用。"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "吸附高亮节点",
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "显示版本更新",
"tooltip": "显示新模型和主要新功能的更新。"
@@ -300,14 +324,14 @@
"Comfy_PromptFilename": {
"name": "保存工作流时提示文件名"
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "批处理计数限制",
"tooltip": "单次添加到队列的最大任务数量"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "队列历史大小",
"tooltip": "队列历史中显示的最大任务数量。"
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "批处理计数限制",
"tooltip": "单次添加到队列的最大任务数量"
},
"Comfy_Sidebar_Location": {
"name": "侧边栏位置",
"options": {
@@ -436,4 +460,4 @@
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}
}
}

View File

@@ -4,7 +4,13 @@
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
v-model="wizardData.url"
:error="uploadError"
class="flex-1"
/>
<UploadModelUrlInputCivitai
v-else-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
@@ -46,14 +52,17 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import UploadModelUrlInputCivitai from '@/platform/assets/components/UploadModelUrlInputCivitai.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()

View File

@@ -1,7 +1,11 @@
<template>
<div class="flex items-center gap-2 p-4 font-bold">
<img src="/assets/images/civitai.svg" class="size-4" />
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<img
v-if="!flags.huggingfaceModelImportEnabled"
src="/assets/images/civitai.svg"
class="size-4"
/>
<span>{{ $t(titleKey) }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
>
@@ -9,3 +13,17 @@
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
const titleKey = computed(() => {
return flags.huggingfaceModelImportEnabled
? 'assetBrowser.uploadModelGeneric'
: 'assetBrowser.uploadModelFromCivitai'
})
</script>

View File

@@ -1,12 +1,34 @@
<template>
<div class="flex justify-end gap-2 w-full">
<div
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
class="mr-auto flex items-center gap-2"
>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<Button
variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-civitai"
@click="showCivitaiHelp = true"
>
{{ $t('assetBrowser.providerCivitai') }}
</Button>
<Button
variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-huggingface"
@click="showHuggingFaceHelp = true"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</Button>
</div>
<Button
v-if="currentStep === 1"
v-else-if="currentStep === 1"
variant="muted-textonly"
size="lg"
class="mr-auto underline"
data-attr="upload-model-step1-help-link"
@click="showVideoHelp = true"
@click="showCivitaiHelp = true"
>
<i class="icon-[lucide--circle-question-mark]" />
<span>{{ $t('assetBrowser.uploadModelHowDoIFindThis') }}</span>
@@ -67,10 +89,15 @@
{{ $t('assetBrowser.finish') }}
</Button>
<VideoHelpDialog
v-model="showVideoHelp"
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
<VideoHelpDialog
v-model="showHuggingFaceHelp"
video-url="https://media.comfy.org/byom/huggingfacehowto.mp4"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
</div>
</template>
@@ -78,9 +105,13 @@
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showVideoHelp = ref(false)
const { flags } = useFeatureFlags()
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{
currentStep: number

View File

@@ -1,28 +1,74 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
</ul>
<div class="flex flex-col justify-between h-full gap-6 text-sm">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<p class="m-0 text-foreground">
{{ $t('assetBrowser.uploadModelDescription1Generic') }}
</p>
<div class="m-0">
<p class="m-0 text-muted-foreground">
{{ $t('assetBrowser.uploadModelDescription2Generic') }}
</p>
<span class="inline-flex items-center gap-1 flex-wrap mt-2">
<span class="inline-flex items-center gap-1">
<img
:src="civitaiIcon"
:alt="$t('assetBrowser.providerCivitai')"
class="w-4 h-4"
/>
<a
:href="civitaiUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerCivitai') }}</a
><span>,</span>
</span>
<span class="inline-flex items-center gap-1">
<img
:src="huggingFaceIcon"
:alt="$t('assetBrowser.providerHuggingFace')"
class="w-4 h-4"
/>
<a
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</a>
</span>
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-foreground">
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
<div class="text-sm text-muted">
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
</div>
</div>
</template>
@@ -44,4 +90,9 @@ const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
const civitaiIcon = '/assets/images/civitai.svg'
const civitaiUrl = 'https://civitai.com/models'
const huggingFaceIcon = '/assets/images/hf-logo.svg'
const huggingFaceUrl = 'https://huggingface.co'
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription2" tag="span">
<template #link>
<a
href="https://civitai.com/models"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.uploadModelDescription2Link') }}
</a>
</template>
</i18n-t>
</li>
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<i18n-t keypath="assetBrowser.civitaiLinkLabel" tag="label" class="mb-0">
<template #download>
<span class="font-bold italic">{{
$t('assetBrowser.civitaiLinkLabelDownload')
}}</span>
</template>
</i18n-t>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<i18n-t
v-else
keypath="assetBrowser.civitaiLinkExample"
tag="p"
class="text-xs"
>
<template #example>
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
</template>
<template #link>
<a
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
</a>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
defineProps<{
error?: string
}>()
const url = defineModel<string>({ required: true })
</script>

View File

@@ -50,10 +50,12 @@ export const useModelTypes = createSharedComposable(() => {
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
return response
.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
.sort((a, b) => a.name.localeCompare(b.name))
},
[] as ModelTypeOption[],
{

View File

@@ -1,9 +1,15 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -21,8 +27,10 @@ interface ModelTypeOption {
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const { flags } = useFeatureFlags()
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
@@ -37,6 +45,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const selectedModelType = ref<string>()
// Available import sources
const importSources: ImportSource[] = flags.huggingfaceModelImportEnabled
? [civitaiImportSource, huggingfaceImportSource]
: [civitaiImportSource]
// Detected import source based on URL
const detectedSource = computed(() => {
const url = wizardData.value.url.trim()
if (!url) return null
return (
importSources.find((source) => validateSourceUrl(url, source)) ?? null
)
})
// Clear error when URL changes
watch(
() => wizardData.value.url,
@@ -54,15 +76,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
return !!selectedModelType.value
})
function isCivitaiUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
} catch {
return false
}
}
async function fetchMetadata() {
if (!canFetchMetadata.value) return
@@ -75,17 +88,36 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',
'Only Civitai URLs are supported'
)
// Validate URL belongs to a supported import source
const source = detectedSource.value
if (!source) {
const supportedSources = importSources.map((s) => s.name).join(', ')
uploadError.value = t('assetBrowser.unsupportedUrlSource', {
sources: supportedSources
})
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
// Decode URL-encoded filenames (e.g., Chinese characters)
if (metadata.filename) {
try {
metadata.filename = decodeURIComponent(metadata.filename)
} catch {
// Keep original if decoding fails
}
}
if (metadata.name) {
try {
metadata.name = decodeURIComponent(metadata.name)
} catch {
// Keep original if decoding fails
}
}
wizardData.value.metadata = metadata
// Pre-fill name from metadata
@@ -125,6 +157,14 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
async function uploadModel() {
if (!canUploadModel.value) return
// Defensive check: detectedSource should be valid after fetchMetadata validation,
// but guard against edge cases (e.g., URL modified between steps)
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
return false
}
isUploading.value = true
uploadStatus.value = 'uploading'
@@ -170,7 +210,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
name: filename,
tags,
user_metadata: {
source: 'civitai',
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
@@ -224,6 +264,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Computed
canFetchMetadata,
canUploadModel,
detectedSource,
// Actions
fetchMetadata,

View File

@@ -0,0 +1,10 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Civitai model import source configuration
*/
export const civitaiImportSource: ImportSource = {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
}

View File

@@ -0,0 +1,10 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Hugging Face model import source configuration
*/
export const huggingfaceImportSource: ImportSource = {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}

View File

@@ -0,0 +1,24 @@
/**
* Supported model import sources
*/
type ImportSourceType = 'civitai' | 'huggingface'
/**
* Configuration for a model import source
*/
export interface ImportSource {
/**
* Unique identifier for this import source
*/
readonly type: ImportSourceType
/**
* Display name for the source
*/
readonly name: string
/**
* Hostname(s) that identify this source
*/
readonly hostnames: readonly string[]
}

View File

@@ -0,0 +1,15 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Check if a URL belongs to a specific import source
*/
export function validateSourceUrl(url: string, source: ImportSource): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return source.hostnames.some(
(h) => hostname === h || hostname.endsWith(`.${h}`)
)
} catch {
return false
}
}

View File

@@ -333,7 +333,7 @@ const { n } = useI18n()
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
const { reportError } = useFirebaseAuthActions()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
@@ -443,9 +443,15 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
loadingTier.value = tierKey
try {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
if (isActiveSubscription.value) {
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
await accessBillingPortal(checkoutTier)
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false

View File

@@ -38,4 +38,5 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
}

View File

@@ -8,7 +8,6 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -44,13 +43,15 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
// Note: layout.size.height is the content height without title.
// LiteGraph's measure() will add titleHeight to get boundingRect.
// Do NOT use addNodeTitleHeight here - that would double-count the title.
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== targetHeight
liteNode.size[1] !== layout.size.height
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, targetHeight])
liteNode.setSize([layout.size.width, layout.size.height])
}
}

View File

@@ -2,6 +2,3 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -43,7 +43,12 @@
)
"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
{{
slotData.label ||
slotData.localized_name ||
slotData.name ||
`Input ${index}`
}}
</span>
</div>
</div>

View File

@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
mocks: {
$t: (key: string) => key
}
},
props: {
widget,
@@ -36,7 +35,7 @@ describe('WidgetImageCompare Display', () => {
}
describe('Component Rendering', () => {
it('renders imagecompare component with proper structure and styling', () => {
it('renders with proper structure and styling when images are provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -44,21 +43,15 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
// Component exists
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
// Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// Images have proper styling classes
// In the new implementation: after image is first (background), before image is second (overlay)
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-contain')
})
})
})
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
expect(customImages[0].attributes('alt')).toBe('Original design')
expect(customImages[1].attributes('alt')).toBe('Updated design')
// DOM order: [after, before]
expect(customImages[0].attributes('alt')).toBe('Updated design')
expect(customImages[1].attributes('alt')).toBe('Original design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
expect(defaultImages[0].attributes('alt')).toBe('Before image')
expect(defaultImages[1].attributes('alt')).toBe('After image')
expect(defaultImages[0].attributes('alt')).toBe('After image')
expect(defaultImages[1].attributes('alt')).toBe('Before image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
expect(emptyImages[0].attributes('alt')).toBe('Before image')
expect(emptyImages[1].attributes('alt')).toBe('After image')
expect(emptyImages[0].attributes('alt')).toBe('After image')
expect(emptyImages[1].attributes('alt')).toBe('Before image')
})
it('handles missing and partial image URLs gracefully', () => {
// Missing URLs
const missingValue: ImageCompareValue = { before: '', after: '' }
const missingWrapper = mountComponent(createMockWidget(missingValue))
const missingImages = missingWrapper.findAll('img')
expect(missingImages[0].attributes('src')).toBe('')
expect(missingImages[1].attributes('src')).toBe('')
// Partial URLs
const partialValue: ImageCompareValue = {
it('handles partial image URLs gracefully', () => {
// Only before image provided
const beforeOnlyValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
const partialWrapper = mountComponent(createMockWidget(partialValue))
const partialImages = partialWrapper.findAll('img')
expect(partialImages[0].attributes('src')).toBe(
const beforeOnlyWrapper = mountComponent(
createMockWidget(beforeOnlyValue)
)
const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
expect(beforeOnlyImages).toHaveLength(1)
expect(beforeOnlyImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
expect(partialImages[1].attributes('src')).toBe('')
// Only after image provided
const afterOnlyValue: ImageCompareValue = {
before: '',
after: 'https://example.com/after.jpg'
}
const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
const afterOnlyImages = afterOnlyWrapper.findAll('img')
expect(afterOnlyImages).toHaveLength(1)
expect(afterOnlyImages[0].attributes('src')).toBe(
'https://example.com/after.jpg'
)
})
})
@@ -129,75 +130,14 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
expect(images[1].attributes('src')).toBe('')
})
it('uses default alt text for string values', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
})
describe('Widget Options Handling', () => {
it('passes through accessibility options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
tabindex: 1,
ariaLabel: 'Compare images',
ariaLabelledby: 'compare-label'
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(1)
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
})
it('uses default tabindex when not provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(0)
})
it('passes through PrimeVue specific options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
unstyled: true,
pt: { root: { class: 'custom-class' } },
ptOptions: { mergeSections: true }
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('unstyled')).toBe(true)
expect(imageCompare.props('pt')).toEqual({
root: { class: 'custom-class' }
})
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
it('renders normally in readonly mode (no interaction restrictions)', () => {
it('renders normally in readonly mode', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -205,45 +145,39 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
// ImageCompare is display-only, readonly doesn't affect rendering
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('handles null or undefined widget value', () => {
it('shows no images message when widget value is empty string', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles empty object value', () => {
it('shows no images message when both URLs are empty', () => {
const value: ImageCompareValue = { before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('shows no images message for empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
})
describe('Template Structure', () => {
it('correctly assigns images to left and right template slots', () => {
it('correctly renders after image as background and before image as overlay', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
// First image (before) should be in left template slot
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
// Second image (after) should be in right template slot
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// After image is rendered first as background
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
// Before image is rendered second as overlay with clipPath
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].classes()).toContain('absolute')
})
})
@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
describe('Slider Element', () => {
it('renders slider divider when images are present', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(true)
expect(slider.classes()).toContain('bg-white')
})
it('does not render slider when no images', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(false)
})
})
})

View File

@@ -1,32 +1,39 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="h-full w-full object-cover"
/>
</template>
<template #right>
<div ref="containerRef" class="relative size-full min-h-32 overflow-hidden">
<div v-if="beforeImage || afterImage">
<img
v-if="afterImage"
:src="afterImage"
:alt="afterAlt"
class="h-full w-full object-cover"
draggable="false"
class="size-full object-contain"
/>
</template>
</ImageCompare>
<img
v-if="beforeImage"
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-contain"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
/>
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
</div>
<div v-else class="flex size-full items-center justify-center">
{{ $t('imageCompare.noImages') }}
</div>
</div>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -43,6 +50,17 @@ const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
}>()
const containerRef = ref<HTMLElement | null>(null)
const sliderPosition = ref(50)
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
if (!outside && width > 0) {
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
}
})
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''

View File

@@ -41,7 +41,11 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { type BaseDOMWidget, DOMWidgetImpl } from '@/scripts/domWidget'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
DOMWidgetImpl
} from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExtensionService } from '@/services/extensionService'
@@ -812,7 +816,10 @@ export class ComfyApp {
> = Object.fromEntries(
newGraph.nodes
.flatMap((node) => node.widgets ?? [])
.filter((w) => w instanceof DOMWidgetImpl)
.filter(
(w) =>
w instanceof DOMWidgetImpl || w instanceof ComponentWidgetImpl
)
.map((w) => [w.id, w])
)

View File

@@ -42,6 +42,11 @@ type AccessBillingPortalResponse =
operations['AccessBillingPortal']['responses']['200']['content']['application/json']
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
export type BillingPortalTargetTier = NonNullable<
NonNullable<
NonNullable<AccessBillingPortalReqBody>['content']
>['application/json']
>['target_tier']
export class FirebaseAuthStoreError extends Error {
constructor(message: string) {
@@ -409,13 +414,15 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
executeAuthAction((_) => addCredits(requestBodyContent))
const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
targetTier?: BillingPortalTargetTier
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const requestBody = targetTier ? { target_tier: targetTier } : undefined
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-[552px] flex-col">
<div class="flex w-138 flex-col">
<ContentDivider :width="1" />
<div class="flex h-full w-full flex-col gap-2 px-4 py-6">
<!-- Description -->
@@ -37,7 +37,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -45,12 +45,12 @@
<div
v-if="importFailedExpanded"
data-testid="conflict-dialog-panel-expanded"
class="flex max-h-[142px] scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
class="flex max-h-35.5 scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ packageName }}
@@ -60,7 +60,10 @@
</div>
</div>
<!-- Conflict List Wrapper -->
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
v-if="allConflictDetails.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@@ -82,7 +85,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -95,7 +98,7 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
@@ -105,7 +108,10 @@
</div>
</div>
<!-- Extension List Wrapper -->
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
v-if="conflictData.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@@ -127,7 +133,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -135,12 +141,12 @@
<div
v-if="extensionsExpanded"
data-testid="conflict-dialog-panel-expanded"
class="flex max-h-[142px] scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
class="flex max-h-35.5 scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ conflictResult.package_name }}

View File

@@ -33,6 +33,8 @@ const mockNodePack = {
const mockIsPackEnabled = vi.fn()
const mockEnablePack = vi.fn().mockResolvedValue(undefined)
const mockDisablePack = vi.fn().mockResolvedValue(undefined)
const mockGetConflictsForPackageByID = vi.fn()
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackEnabled: mockIsPackEnabled,
@@ -42,6 +44,12 @@ vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
useConflictDetectionStore: vi.fn(() => ({
getConflictsForPackageByID: mockGetConflictsForPackageByID
}))
}))
describe('PackEnableToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -163,4 +171,41 @@ describe('PackEnableToggle', () => {
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(false)
})
describe('conflict warning icon', () => {
it('should show warning icon when package has conflicts', () => {
mockGetConflictsForPackageByID.mockReturnValue({
package_id: 'test-pack',
package_name: 'Test Pack',
has_conflict: true,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'error message'
}
],
is_compatible: false
})
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
// Check if warning icon exists
const warningIcon = wrapper.find('.pi-exclamation-triangle')
expect(warningIcon.exists()).toBe(true)
expect(warningIcon.classes()).toContain('text-yellow-500')
})
it('should not show warning icon when package has no conflicts', () => {
mockGetConflictsForPackageByID.mockReturnValue(null)
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
// Check if warning icon does not exist
const warningIcon = wrapper.find('.pi-exclamation-triangle')
expect(warningIcon.exists()).toBe(false)
})
})
})

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-if="packageConflict?.has_conflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
@@ -43,9 +43,8 @@ import type { components as ManagerComponents } from '@/workbench/extensions/man
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack, hasConflict } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
@@ -68,14 +67,14 @@ const version = computed(() => {
)
})
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const packageConflict = computed(() => {
if (!nodePack.id) return undefined
return getConflictsForPackageByID(nodePack.id)
})
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
packageConflict.value?.has_conflict &&
!acknowledgmentState.value.modal_dismissed
)
})

View File

@@ -0,0 +1,177 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import PackCardFooter from './PackCardFooter.vue'
// Mock the child components
vi.mock(
'@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue',
() => ({
default: { template: '<div data-testid="pack-install-button"></div>' }
})
)
vi.mock(
'@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue',
() => ({
default: { template: '<div data-testid="pack-enable-toggle"></div>' }
})
)
// Mock composables
const mockIsPackInstalled = vi.fn()
const mockCheckNodeCompatibility = vi.fn()
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackInstalled: mockIsPackInstalled
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
})
)
// Remove the mock for injection key since we're importing it directly
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
downloads: 1000
}
describe('PackCardFooter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPackInstalled.mockReset()
mockCheckNodeCompatibility.mockReset()
})
const mountComponent = (props = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackCardFooter, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
provide: {
[IsInstallingKey]: ref(false)
}
}
})
}
describe('component rendering', () => {
it('shows download count when available', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
expect(wrapper.text()).toContain('1,000')
})
it('shows install button for uninstalled packages', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="pack-install-button"]').exists()).toBe(
true
)
expect(wrapper.find('[data-testid="pack-enable-toggle"]').exists()).toBe(
false
)
})
it('shows enable toggle for installed packages', () => {
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="pack-enable-toggle"]').exists()).toBe(
true
)
expect(wrapper.find('[data-testid="pack-install-button"]').exists()).toBe(
false
)
})
})
describe('conflict detection for uninstalled packages', () => {
it('passes conflict info to install button when conflicts exist', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: true,
conflicts: [
{
type: 'os_conflict',
current_value: 'windows',
required_value: 'linux'
}
]
})
const wrapper = mountComponent()
const installButton = wrapper.find('[data-testid="pack-install-button"]')
expect(installButton.exists()).toBe(true)
// The install button should receive has-conflict prop as true
expect(installButton.attributes()).toHaveProperty('has-conflict')
})
it('does not pass conflict info when no conflicts exist', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
const installButton = wrapper.find('[data-testid="pack-install-button"]')
expect(installButton.exists()).toBe(true)
// The install button should receive has-conflict prop as false
expect(installButton.attributes()['has-conflict']).toBe('false')
})
})
describe('installed packages', () => {
it('does not pass has-conflict prop to enable toggle', () => {
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountComponent()
const enableToggle = wrapper.find('[data-testid="pack-enable-toggle"]')
expect(enableToggle.exists()).toBe(true)
// The enable toggle should not receive has-conflict prop (removed in our fix)
expect(enableToggle.attributes()).not.toHaveProperty('has-conflict')
})
})
})

View File

@@ -13,11 +13,7 @@
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
<PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
<PackEnableToggle v-else :node-pack="nodePack" />
</div>
</template>

View File

@@ -7,6 +7,7 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
@@ -87,9 +88,11 @@ export function useConflictDetection() {
try {
// Get system stats from store (primary source of system information)
// Wait for systemStats to be initialized if not already
const { systemStats, isInitialized: systemStatsInitialized } =
useSystemStatsStore()
await until(systemStatsInitialized)
const systemStatsStore = useSystemStatsStore()
const { systemStats } = systemStatsStore
// Wait for initialization using the store's isInitialized property (correct reactive way)
await until(() => systemStatsStore.isInitialized).toBe(true)
const frontendVersion = getFrontendVersion()
@@ -548,9 +551,11 @@ export function useConflictDetection() {
*/
async function initializeConflictDetection(): Promise<void> {
try {
// Check if manager is new Manager before proceeding
const { useManagerState } =
await import('@/workbench/extensions/manager/composables/useManagerState')
// First, wait for systemStats to be initialized
const systemStatsStore = useSystemStatsStore()
await until(() => systemStatsStore.isInitialized).toBe(true)
// Now check if manager is new Manager
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {