Move workflow/widgets into component

This commit is contained in:
Austin Mroz
2026-01-07 22:46:46 -08:00
parent 532b8f0454
commit aa91b52f6b
3 changed files with 288 additions and 274 deletions

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, shallowRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { safeWidgetMapper } from '@/composables/graph/useGraphNodeManager'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const { isActiveSubscription } = useSubscription()
const showNoteData = ref(false)
defineProps<{
toastTo: string | HTMLElement
notesTo: string | HTMLElement
}>()
const jobFinishedQueue = ref(true)
const {
ready: jobToastTimeout,
start: resetJobToastTimeout,
stop: stopJobTimeout
} = useTimeout(5000, { controls: true })
stopJobTimeout()
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
function nodeToNodeData(node: LGraphNode) {
const mapper = safeWidgetMapper(node, new Map())
const widgets = node.widgets?.map(mapper) ?? []
const dropIndicator =
node.type !== 'LoadImage'
? undefined
: {
iconClass: 'icon-[lucide--image]',
label: t('linearMode.dragAndDropImage')
}
//of VueNodeData, only widgets is actually used
return {
executing: false,
id: `${node.id}`,
mode: 0,
selected: false,
title: node.title,
type: node.type,
widgets,
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
const nodeDatas = computed(() => {
return graphNodes.value
.filter(
(node) =>
node.mode === 0 &&
node.widgets?.length &&
!['MarkdownNote', 'Note'].includes(node.type)
)
.map(nodeToNodeData)
.reverse()
})
const noteDatas = computed(() => {
return graphNodes.value
.filter(
(node) => node.mode === 0 && ['MarkdownNote', 'Note'].includes(node.type)
)
.map(nodeToNodeData)
})
const batchCountWidget = {
options: { precision: 0, min: 1, max: 99 },
value: 1,
name: t('linearMode.runCount'),
type: 'number'
}
const { batchCount } = storeToRefs(useQueueSettingsStore())
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
if (!jobFinishedQueue.value) return
try {
jobFinishedQueue.value = false
resetJobToastTimeout()
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_linear'
})
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
} finally {
//TODO: Error state indicator for failed queue?
jobFinishedQueue.value = true
}
}
defineExpose({ runButtonClick })
</script>
<template>
<div class="flex flex-col min-w-80 h-full">
<linear-workflow-info
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center"
>
<span
class="font-bold truncate min-w-30"
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Button
v-if="noteDatas.length"
variant="muted-textonly"
@click="showNoteData = !showNoteData"
>
<i class="icon-[lucide--info]" />
</Button>
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</linear-workflow-info>
<div
class="border gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
>
<linear-widgets class="grow-1 overflow-y-auto contain-size">
<template v-for="(nodeData, index) of nodeDatas" :key="nodeData.id">
<div
v-if="index !== 0"
class="w-full border-t-1 border-node-component-border"
/>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
class="py-3 gap-y-4 **:[.col-span-2]:grid-cols-1 text-sm **:[.p-floatlabel]:h-35"
/>
</DropZone>
</template>
</linear-widgets>
<linear-run-button class="p-4 pb-6 border-t border-node-component-border">
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="w-full mt-4"
/>
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
</linear-run-button>
</div>
</div>
<teleport v-if="!jobToastTimeout || !jobFinishedQueue" defer :to="toastTo">
<div
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 bg-success-background"
/>
<ProgressSpinner v-else class="size-4" />
<span v-text="t('queue.jobAddedToQueue')" />
</div>
</teleport>
<teleport v-if="showNoteData" defer :to="notesTo">
<div
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
>
<Button
variant="muted-textonly"
size="icon"
class="self-end mr-3"
@click="showNoteData = false"
>
<i class="icon-[lucide--x]" />
</Button>
<template v-for="nodeData in noteDatas" :key="nodeData.id">
<div class="w-full border-t border-border-subtle" />
<NodeWidgets
:node-data
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0"
/>
</template>
</div>
</teleport>
</template>

View File

@@ -43,15 +43,6 @@ const emit = defineEmits<{
defineExpose({ onWheel })
/*
Should be sole holder of all output state
Should emit selected item
Should take location of scroll to top as prop
Need way to expose handlewheel to parent
- expose is least bad option
*/
const selectedIndex = ref<[number, number]>([0, 0])
watch(selectedIndex, () => {

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { useEventListener, useTimeout, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { whenever } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed, ref, shallowRef, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { downloadFile } from '@/base/common/downloadUtil'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
@@ -12,19 +10,14 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { safeWidgetMapper } from '@/composables/graph/useGraphNodeManager'
import { d, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import LinearWorkflow from '@/renderer/extensions/linearMode/LinearWorkflow.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import {
@@ -32,159 +25,29 @@ import {
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const nodeOutputStore = useNodeOutputStore()
const settingStore = useSettingStore()
const { isActiveSubscription } = useSubscription()
const workflowStore = useWorkflowStore()
const showNoteData = ref(false)
const hasPreview = ref(false)
whenever(
() => nodeOutputStore.latestPreview[0],
() => (hasPreview.value = true)
)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
function nodeToNodeData(node: LGraphNode) {
const mapper = safeWidgetMapper(node, new Map())
const widgets = node.widgets?.map(mapper) ?? []
const dropIndicator =
node.type !== 'LoadImage'
? undefined
: {
iconClass: 'icon-[lucide--image]',
label: t('linearMode.dragAndDropImage')
}
//of VueNodeData, only widgets is actually used
return {
executing: false,
id: `${node.id}`,
mode: 0,
selected: false,
title: node.title,
type: node.type,
widgets,
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
const nodeDatas = computed(() => {
return graphNodes.value
.filter(
(node) =>
node.mode === 0 &&
node.widgets?.length &&
!['MarkdownNote', 'Note'].includes(node.type)
)
.map(nodeToNodeData)
.reverse()
})
const noteDatas = computed(() => {
return graphNodes.value
.filter(
(node) => node.mode === 0 && ['MarkdownNote', 'Note'].includes(node.type)
)
.map(nodeToNodeData)
})
const batchCountWidget = {
options: { precision: 0, min: 1, max: 99 },
value: 1,
name: t('linearMode.runCount'),
type: 'number'
}
const { batchCount } = storeToRefs(useQueueSettingsStore())
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
if (!jobFinishedQueue.value) return
try {
jobFinishedQueue.value = false
resetJobToastTimeout()
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_linear'
})
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
} finally {
//TODO: Error state indicator for failed queue?
jobFinishedQueue.value = true
}
}
const jobFinishedQueue = ref(true)
const {
ready: jobToastTimeout,
start: resetJobToastTimeout,
stop: stopJobTimeout
} = useTimeout(5000, { controls: true })
stopJobTimeout()
function loadWorkflow(item: AssetItem | undefined, index: [number, number]) {
const workflow = getOutputAssetMetadata(item?.user_metadata)?.workflow
if (!workflow) return
selectedIndex.value = index
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
//update graph to new version, set old to top of undo queue
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
loadWorkflow(selectedItem.value, selectedIndex.value)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
selectedIndex.value = [0, 0]
runButtonClick(e)
}
const selectedItem = ref<AssetItem | undefined>()
const selectedOutput = ref<ResultItemImpl | undefined>()
const selectedIndex = ref<[number, number]>([0, 0])
const outputHistoryRef = useTemplateRef('outputHistoryRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
const dateOptions = {
month: 'short',
day: 'numeric',
@@ -233,6 +96,31 @@ function downloadAsset(item?: AssetItem) {
for (const output of user_metadata?.allOutputs ?? [])
downloadFile(output.url, output.filename)
}
function loadWorkflow(item: AssetItem | undefined, index: [number, number]) {
const workflow = getOutputAssetMetadata(item?.user_metadata)?.workflow
if (!workflow) return
selectedIndex.value = index
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
//update graph to new version, set old to top of undo queue
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
const runButtonClick = linearWorkflowRef.value?.runButtonClick
if (!runButtonClick) return
loadWorkflow(selectedItem.value, selectedIndex.value)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
selectedIndex.value = [0, 0]
runButtonClick(e)
}
</script>
<template>
<div class="absolute w-full h-full">
@@ -263,6 +151,12 @@ function downloadAsset(item?: AssetItem) {
}
"
/>
<LinearWorkflow
v-else
ref="linearWorkflowRef"
toast-to="#linearDockBottomLeft"
notes-to="#linearDockTopLeft"
/>
<div />
</SplitterPanel>
<SplitterPanel
@@ -284,8 +178,14 @@ function downloadAsset(item?: AssetItem) {
:size="1"
class="min-w-min outline-none"
>
<LinearWorkflow
v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
ref="linearWorkflowRef"
toast-to="#linearDockBottomRight"
notes-to="#linearDockTopRight"
/>
<OutputHistory
v-if="settingStore.get('Comfy.Sidebar.Location') === 'right'"
v-else
ref="outputHistoryRef"
scroll-reset-button-to="#linearDockBottomRight"
@update-selection="
@@ -395,127 +295,5 @@ function downloadAsset(item?: AssetItem) {
: '#linearLeftPanel'
"
>
<div class="flex flex-col min-w-80 h-full">
<linear-workflow-info
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center"
>
<span
class="font-bold truncate min-w-30"
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Button
v-if="noteDatas.length"
variant="muted-textonly"
@click="showNoteData = !showNoteData"
>
<i class="icon-[lucide--info]" />
</Button>
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</linear-workflow-info>
<div
class="border gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
>
<linear-widgets class="grow-1 overflow-y-auto contain-size">
<template v-for="(nodeData, index) of nodeDatas" :key="nodeData.id">
<div
v-if="index !== 0"
class="w-full border-t-1 border-node-component-border"
/>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
class="py-3 gap-y-4 **:[.col-span-2]:grid-cols-1 text-sm **:[.p-floatlabel]:h-35"
/>
</DropZone>
</template>
</linear-widgets>
<linear-run-button
class="p-4 pb-6 border-t border-node-component-border"
>
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="w-full mt-4"
/>
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
</linear-run-button>
</div>
</div>
<teleport
:to="
settingStore.get('Comfy.Sidebar.Location') === 'left'
? '#linearDockBottomRight'
: '#linearDockBottomLeft'
"
>
<div
v-if="!jobToastTimeout || !jobFinishedQueue"
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 bg-success-background"
/>
<ProgressSpinner v-else class="size-4" />
<span v-text="t('queue.jobAddedToQueue')" />
</div>
</teleport>
<teleport
:to="
settingStore.get('Comfy.Sidebar.Location') === 'left'
? '#linearDockTopRight'
: '#linearDockTopLeft'
"
>
<div
v-if="showNoteData"
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
>
<Button
variant="muted-textonly"
size="icon"
class="self-end mr-3"
@click="showNoteData = false"
>
<i class="icon-[lucide--x]" />
</Button>
<template v-for="nodeData in noteDatas" :key="nodeData.id">
<div class="w-full border-t border-border-subtle" />
<NodeWidgets
:node-data
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0"
/>
</template>
</div>
</teleport>
</teleport>
</template>