Compare commits

...

2 Commits

Author SHA1 Message Date
Alexis Rolland
a4f0af9a13 Init API mode POC 2026-07-01 09:51:29 +08:00
Alexis Rolland
0adcfb32e6 Init API mode POC 2026-07-01 09:47:46 +08:00
34 changed files with 1807 additions and 161 deletions

View File

@@ -120,6 +120,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"swagger-ui-dist": "catalog:",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",

18
pnpm-lock.yaml generated
View File

@@ -327,6 +327,9 @@ catalogs:
stylelint:
specifier: ^16.26.1
version: 16.26.1
swagger-ui-dist:
specifier: ^5.32.8
version: 5.32.8
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
@@ -606,6 +609,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
swagger-ui-dist:
specifier: 'catalog:'
version: 5.32.8
three:
specifier: 'catalog:'
version: 0.184.0
@@ -3326,6 +3332,9 @@ packages:
'@rushstack/ts-command-line@5.3.1':
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
@@ -8007,6 +8016,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
swagger-ui-dist@5.32.8:
resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==}
swr@2.4.0:
resolution: {integrity: sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==}
peerDependencies:
@@ -11205,6 +11217,8 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@scarf/scarf@1.4.0': {}
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
@@ -16802,6 +16816,10 @@ snapshots:
picocolors: 1.1.1
sax: 1.6.0
swagger-ui-dist@5.32.8:
dependencies:
'@scarf/scarf': 1.4.0
swr@2.4.0(react@19.2.4):
dependencies:
dequal: 2.0.3

View File

@@ -118,6 +118,7 @@ catalog:
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
swagger-ui-dist: ^5.32.8
tailwindcss: ^4.3.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
@@ -153,6 +154,7 @@ cleanupUnusedCatalogs: true
allowBuilds:
'@firebase/util': false
'@scarf/scarf': false
'@sentry/cli': true
'@tailwindcss/oxide': true
core-js: false

View File

@@ -21,7 +21,7 @@ import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const { enableAppBuilder, isApiMode } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
@@ -35,6 +35,10 @@ const isAssetsActive = computed(
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
)
// The Apps/APIs sidebar button reflects the current mode.
const appsIcon = computed(() =>
isApiMode.value ? 'icon-[lucide--cloud]' : 'icon-[lucide--panels-top-left]'
)
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
@@ -110,7 +114,7 @@ function showApps() {
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i :class="cn(appsIcon, 'size-4')" />
</Button>
</div>
</div>

View File

@@ -10,6 +10,10 @@ import type { AppMode } from '@/utils/appMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockCanvasState = vi.hoisted(() => ({
builderEnteredFromApi: false,
apiShowSwagger: false
}))
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockSave = vi.hoisted(() => vi.fn())
const mockSaveAs = vi.hoisted(() => vi.fn())
@@ -26,6 +30,10 @@ vi.mock('@/composables/useAppMode', () => ({
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasState
}))
const mockHasOutputs = ref(true)
vi.mock('@/stores/appModeStore', () => ({
@@ -104,6 +112,8 @@ describe('BuilderFooterToolbar', () => {
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
mockCanvasState.builderEnteredFromApi = false
mockCanvasState.apiShowSwagger = false
})
function renderComponent() {
@@ -174,6 +184,14 @@ describe('BuilderFooterToolbar', () => {
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('calls setMode api on view click for an API builder session', async () => {
mockCanvasState.builderEnteredFromApi = true
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /view app/i }))
expect(mockSetMode).toHaveBeenCalledWith('api')
expect(mockCanvasState.apiShowSwagger).toBe(true)
})
it('shows "Save as" when workflow is temporary', () => {
mockActiveWorkflow.value = { isTemporary: true }
renderComponent()

View File

@@ -139,6 +139,7 @@ import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
@@ -151,6 +152,7 @@ const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const { isBuilderMode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const {
@@ -202,7 +204,14 @@ function onExitBuilder() {
}
function onViewApp() {
setMode('app')
// API builder sessions open the generated Swagger (API mode) rather than the
// App-mode preview.
if (canvasStore.builderEnteredFromApi) {
canvasStore.apiShowSwagger = true
setMode('api')
} else {
setMode('app')
}
}
function onSetDefaultView(openAsApp: boolean) {

View File

@@ -34,6 +34,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -45,7 +46,7 @@ import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const { activeStep, navigateToStep } = useBuilderSteps()
const { steps: activeStepIds, activeStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
@@ -71,5 +72,11 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
icon: 'icon-[lucide--layout-panel-left]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
const stepsById: Record<BuilderStepId, BuilderToolbarStep<BuilderStepId>> = {
'builder:inputs': selectInputsStep,
'builder:outputs': selectOutputsStep,
'builder:arrange': arrangeStep
}
const steps = computed(() => activeStepIds.value.map((id) => stepsById[id]))
</script>

View File

@@ -63,6 +63,15 @@ vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: mockCloseDialog })
}))
const mockCanvasState = vi.hoisted(() => ({
builderEnteredFromApi: false,
apiShowSwagger: false
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasState
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: mockShowConfirmDialog
}))
@@ -85,6 +94,8 @@ describe('useBuilderSave', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveWorkflow.value = null
mockCanvasState.builderEnteredFromApi = false
mockCanvasState.apiShowSwagger = false
})
describe('save()', () => {
@@ -337,5 +348,15 @@ describe('useBuilderSave', () => {
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('opens the Swagger (API mode) for an API builder session', async () => {
mockCanvasState.builderEnteredFromApi = true
const { onCancel } = await getGraphSuccessDialogProps()
onCancel()
expect(mockSetMode).toHaveBeenCalledWith('api')
expect(mockCanvasState.apiShowSwagger).toBe(true)
})
})
})

View File

@@ -5,6 +5,7 @@ import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -25,11 +26,23 @@ export function useBuilderSave() {
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const canvasStore = useCanvasStore()
function closeDialog(key: string) {
dialogStore.closeDialog({ key })
}
// "View App" / "View API": API builder sessions open the generated Swagger
// (API mode) instead of the App-mode preview.
function viewAppOrApi() {
if (canvasStore.builderEnteredFromApi) {
canvasStore.apiShowSwagger = true
setMode('api')
} else {
setMode('app')
}
}
async function save() {
if (isSaving.value) return
const workflow = workflowStore.activeWorkflow
@@ -110,7 +123,7 @@ export function useBuilderSave() {
onCancel: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
viewAppOrApi()
},
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
@@ -125,7 +138,7 @@ export function useBuilderSave() {
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
viewAppOrApi()
}
}
})

View File

@@ -3,6 +3,7 @@ import type { Ref } from 'vue'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
const BUILDER_STEPS = [
'builder:inputs',
@@ -12,10 +13,19 @@ const BUILDER_STEPS = [
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const canvasStore = useCanvasStore()
// API builder sessions skip the final "arrange" preview step, which only
// shapes the App-mode layout and is not useful for an API.
const steps = computed<readonly BuilderStepId[]>(() =>
canvasStore.builderEnteredFromApi
? BUILDER_STEPS.filter((step) => step !== 'builder:arrange')
: BUILDER_STEPS
)
const arrangeIndex = computed(() => steps.value.indexOf('builder:arrange'))
const activeStep = computed<BuilderStepId>(() => {
if (isBuilderMode.value) {
@@ -24,16 +34,14 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
return 'builder:inputs'
})
const activeStepIndex = computed(() =>
BUILDER_STEPS.indexOf(activeStep.value)
)
const activeStepIndex = computed(() => steps.value.indexOf(activeStep.value))
const isFirstStep = computed(() => activeStepIndex.value === 0)
const isLastStep = computed(() => {
if (!options?.hasOutputs?.value)
return activeStepIndex.value >= ARRANGE_INDEX
return activeStepIndex.value >= BUILDER_STEPS.length - 1
if (!options?.hasOutputs?.value && arrangeIndex.value >= 0)
return activeStepIndex.value >= arrangeIndex.value
return activeStepIndex.value >= steps.value.length - 1
})
const isSelectStep = computed(
@@ -44,15 +52,16 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
function goBack() {
if (isFirstStep.value) return
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
setMode(steps.value[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
setMode(steps.value[activeStepIndex.value + 1])
}
return {
steps,
activeStep,
activeStepIndex,
isFirstStep,

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -46,21 +46,37 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const isGraphMode = computed(
() => !canvasStore.linearMode && !canvasStore.apiMode
)
function appModeTooltip() {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
return (
t('breadcrumbsMenu.enterAppMode') +
(shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
)
}
function toggleLinearMode() {
function enterGraphMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
canvasStore.linearMode = false
canvasStore.apiMode = false
}
function enterAppMode() {
dropdownOpen.value = false
if (!canvasStore.linearMode) {
useTelemetry()?.trackEnterLinear({ source })
}
canvasStore.linearMode = true
}
function enterApiMode() {
dropdownOpen.value = false
canvasStore.apiMode = true
}
const tooltipPt = {
@@ -92,29 +108,51 @@ const tooltipPt = {
>
<Button
v-tooltip.bottom="{
value: toggleModeTooltip(),
value: appModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
:aria-label="t('breadcrumbsMenu.enterAppMode')"
variant="base"
class="m-1"
:class="{ 'bg-secondary-background-hover': !canvasStore.linearMode }"
@pointerdown.stop
@click="toggleLinearMode"
@click="enterAppMode"
>
<i
class="size-4"
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
<Button
v-tooltip.bottom="{
value: t('breadcrumbsMenu.enterApiMode'),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="t('breadcrumbsMenu.enterApiMode')"
variant="base"
class="m-1"
:class="{ 'bg-secondary-background-hover': !canvasStore.apiMode }"
@pointerdown.stop
@click="enterApiMode"
>
<i class="icon-[lucide--cloud] size-4" />
</Button>
<Button
v-tooltip.bottom="{
value: t('breadcrumbsMenu.enterGraphMode'),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="t('breadcrumbsMenu.enterGraphMode')"
variant="base"
class="m-1"
:class="{ 'bg-secondary-background-hover': !isGraphMode }"
@pointerdown.stop
@click="enterGraphMode"
>
<i class="icon-[comfy--workflow] size-4" />
</Button>
<DropdownMenuTrigger as-child>
<Button
@@ -128,11 +166,7 @@ const tooltipPt = {
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<span>{{ t('breadcrumbsMenu.actions') }}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>

View File

@@ -10,11 +10,7 @@
@mouseup="handleMouseUp"
@click="handleClick"
>
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
<i
v-else-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<i :class="tabModeIcon" />
<span
class="workflow-label inline-block max-w-[150px] truncate text-sm"
>
@@ -104,6 +100,7 @@ import {
} from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { getWorkflowMode } from '@/utils/appMode'
import { cn } from '@comfyorg/tailwind-utils'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -175,6 +172,19 @@ const isBuilderState = computed(() => {
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
})
// Icon reflects the workflow's current mode so the tab matches the active view.
const tabModeIcon = computed(() => {
if (isBuilderState.value) return 'bg-text-subtle icon-[lucide--hammer]'
switch (getWorkflowMode(props.workflowOption.workflow)) {
case 'api':
return 'icon-[lucide--cloud] bg-primary-background'
case 'app':
return 'icon-[lucide--panels-top-left] bg-primary-background'
default:
return 'bg-text-subtle icon-[comfy--workflow]'
}
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})

View File

@@ -1,7 +1,11 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
import {
getWorkflowMode,
isApiModeValue,
isAppModeValue
} from '@/utils/appMode'
import type { AppMode } from '@/utils/appMode'
const enableAppBuilder = ref(true)
@@ -20,6 +24,7 @@ export function useAppMode() {
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(() => isAppModeValue(mode.value))
const isApiMode = computed(() => isApiModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)
@@ -38,6 +43,7 @@ export function useAppMode() {
isSelectOutputsMode,
isArrangeMode,
isAppMode,
isApiMode,
isGraphMode,
setMode
}

View File

@@ -1353,6 +1353,14 @@ export function useCoreCommands(): ComfyCommand[] {
if (newMode) useTelemetry()?.trackEnterLinear({ source })
canvasStore.linearMode = newMode
}
},
{
id: 'Comfy.ToggleApiMode',
icon: 'pi pi-server',
label: 'Toggle API Mode',
function: () => {
canvasStore.apiMode = !canvasStore.apiMode
}
}
]

View File

@@ -161,44 +161,7 @@ describe('useWorkflowActionsMenu', () => {
expect(labels).not.toContain('breadcrumbsMenu.deleteWorkflow')
})
it('shows app mode items when linearToggleEnabled flag is set', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('shows app mode items when user has seen linear mode', () => {
mockMenuItemStore.hasSeenLinear = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when conditions not met', () => {
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when not root', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('shows "go to workflow mode" when in linear mode', () => {
it('shows builder app mode items when linearToggleEnabled flag is set', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
@@ -209,7 +172,59 @@ describe('useWorkflowActionsMenu', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.exitAppMode')
expect(labels).toContain('breadcrumbsMenu.enterBuilderMode')
})
it('hides the build app item when in graph mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterBuilderMode')
})
it('does not show the enter app mode item in the actions menu', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockMenuItemStore.hasSeenLinear = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('hides builder app mode items when conditions not met', () => {
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterBuilderMode')
})
it('hides builder app mode items when not root', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterBuilderMode')
})
it('does not show the exit app mode item when in app mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.exitAppMode')
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
@@ -225,11 +240,16 @@ describe('useWorkflowActionsMenu', () => {
it('adds badge to app mode items', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const appModeItem = findItem(
menuItems.value,
'breadcrumbsMenu.enterAppMode'
'breadcrumbsMenu.enterBuilderMode'
)
expect(appModeItem.badge).toBeDefined()
@@ -312,6 +332,11 @@ describe('useWorkflowActionsMenu', () => {
it('enter builder mode calls enterBuilder', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(
@@ -326,7 +351,8 @@ describe('useWorkflowActionsMenu', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
mockAppModeStore.selectedInputs.push([1, 'widget'])
mockAppModeStore.selectedOutputs.push(2)
@@ -338,18 +364,6 @@ describe('useWorkflowActionsMenu', () => {
expect(item.isNew).toBeTruthy()
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'breadcrumbsMenu.enterAppMode').command?.()
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.ToggleLinear',
{ metadata: { source: 'breadcrumb_menu' } }
)
})
it('rename is disabled for unpersisted root workflows', () => {
mockWorkflowStore.activeWorkflow = {
path: 'test.json',

View File

@@ -97,17 +97,11 @@ export function useWorkflowActionsMenu(
const workflowMode =
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
const isLinearMode = workflowMode === 'app'
const isGraphMode = workflowMode === 'graph'
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
await ensureWorkflowActive(targetWorkflow.value)
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
}
addItem({
id: 'rename',
label: t('g.rename'),
@@ -201,25 +195,6 @@ export function useWorkflowActionsMenu(
visible: isCloud && flags.workflowSharingEnabled
})
addItem({
id: 'enter-app-mode',
label: t('breadcrumbsMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
command: toggleLinear,
visible: showAppModeItems && !isLinearMode,
prependSeparator: true,
isNew: true
})
addItem({
id: 'exit-app-mode',
label: t('breadcrumbsMenu.exitAppMode'),
icon: 'icon-[comfy--workflow]',
command: toggleLinear,
visible: isLinearMode,
prependSeparator: true
})
const isActive = workflow === workflowStore.activeWorkflow
const rawLd = isActive
? {
@@ -245,7 +220,8 @@ export function useWorkflowActionsMenu(
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder()
},
visible: showAppModeItems,
visible: showAppModeItems && !isGraphMode,
prependSeparator: true,
isNew: true
})

View File

@@ -140,7 +140,44 @@ const messages: Partial<Record<SupportedLocale, LocaleMessages>> = {
en: enMessages
}
/**
* When API mode is active, user-facing "App" wording is rewritten to "API".
* The check is injected at runtime (see `setApiModeChecker`) to avoid importing
* the Pinia canvas store into this early-loaded module.
*/
let isApiModeActive: () => boolean = () => false
export function setApiModeChecker(checker: () => boolean): void {
isApiModeActive = checker
}
// Mode-switch affordances (the toolbar buttons) must keep their literal "App"
// wording so users can still tell the modes apart while inside API mode.
const API_MODE_LABEL_EXCLUDED_KEYS = new Set<string>([
'breadcrumbsMenu.enterAppMode',
'breadcrumbsMenu.exitAppMode',
'breadcrumbsMenu.enterApiMode',
'breadcrumbsMenu.exitApiMode',
'breadcrumbsMenu.enterGraphMode',
'breadcrumbsMenu.actions',
'builderMenu.enterAppMode'
])
function apifyAppLabel(text: string): string {
return text
.replace(/\bApps\b/g, 'APIs')
.replace(/\bapps\b/g, 'APIs')
.replace(/\bApp\b/g, 'API')
.replace(/\bapp\b/g, 'API')
}
export const i18n = createI18n({
postTranslation: (translated, key) => {
if (typeof translated !== 'string') return translated
if (!isApiModeActive()) return translated
if (API_MODE_LABEL_EXCLUDED_KEYS.has(key)) return translated
return apifyAppLabel(translated)
},
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),

View File

@@ -3053,8 +3053,12 @@
"breadcrumbsMenu": {
"blueprint": "Blueprint",
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"enterAppMode": "App mode",
"exitAppMode": "Exit app mode",
"enterApiMode": "API mode",
"exitApiMode": "Exit API mode",
"enterGraphMode": "Graph mode",
"actions": "Actions",
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"workflowActions": "Workflow actions",
@@ -3698,6 +3702,8 @@
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"apiMessage": "Create an API for your workflow.",
"apiSharing": "Share the endpoints with others and they can see a Swagger documentation to learn how to send requests easily.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,10 +8,28 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
const mockAppModeState = vi.hoisted(() => ({
isApiMode: { value: false },
setMode: vi.fn()
}))
const mockActiveWorkflow = vi.hoisted(
() => ({ value: null }) as { value: unknown }
)
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: { value: false },
setMode: vi.fn()
isApiMode: mockAppModeState.isApiMode,
isBuilderMode: { value: false },
setMode: mockAppModeState.setMode
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
@@ -46,6 +64,44 @@ describe('useCanvasStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useCanvasStore()
vi.clearAllMocks()
mockActiveWorkflow.value = null
})
describe('apiMode entry', () => {
// Use a real pinia so the writable computed setter runs (createTestingPinia
// makes getters overridable, which would bypass the setter under test).
let realStore: ReturnType<typeof useCanvasStore>
beforeEach(() => {
setActivePinia(createPinia())
realStore = useCanvasStore()
})
it('opens the Swagger when the workflow already has linear data', () => {
mockActiveWorkflow.value = {
changeTracker: {
activeState: { extra: { linearData: { inputs: [['1', 'text']] } } }
}
}
realStore.apiMode = true
expect(realStore.apiShowSwagger).toBe(true)
expect(mockAppModeState.setMode).toHaveBeenCalledWith('api')
})
it('opens the builder/preview when the workflow has no linear data', () => {
mockActiveWorkflow.value = {
changeTracker: {
activeState: { extra: { linearData: { inputs: [], outputs: [] } } }
}
}
realStore.apiMode = true
expect(realStore.apiShowSwagger).toBe(false)
expect(mockAppModeState.setMode).toHaveBeenCalledWith('api')
})
})
describe('appScalePercentage', () => {

View File

@@ -1,9 +1,11 @@
import { useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef } from 'vue'
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
import type { Raw } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { setApiModeChecker } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -49,13 +51,57 @@ export const useCanvasStore = defineStore('canvas', () => {
appScalePercentage.value = Math.round(scale * 100)
}
const { isAppMode, setMode } = useAppMode()
const { isAppMode, isApiMode, isBuilderMode, setMode } = useAppMode()
const workflowStore = useWorkflowStore()
// Whether the active workflow already has linear data, i.e. the app/API has
// already been built. When true, entering app/API mode opens the result
// (the app preview / generated Swagger) instead of the builder/preview.
function activeWorkflowHasLinearData(): boolean {
const linearData =
workflowStore.activeWorkflow?.changeTracker?.activeState?.extra
?.linearData
return (
(linearData?.inputs?.length ?? 0) > 0 ||
(linearData?.outputs?.length ?? 0) > 0
)
}
const linearMode = computed({
get: () => isAppMode.value,
set: (val: boolean) => {
setMode(val ? 'app' : 'graph')
}
})
// When true, API mode renders the generated Swagger ("View API" result)
// instead of the API builder/preview. Set by the builder's "View API" action,
// or when entering API mode for a workflow that's already been built.
const apiShowSwagger = ref(false)
const apiMode = computed({
get: () => isApiMode.value,
set: (val: boolean) => {
apiShowSwagger.value = val && activeWorkflowHasLinearData()
setMode(val ? 'api' : 'graph')
}
})
watch(isApiMode, (inApi) => {
if (!inApi) apiShowSwagger.value = false
})
// The builder is shared between App mode and API mode. Track whether the
// current builder session was entered from API mode so labels can stay "API".
// Set by `appModeStore.enterBuilder`; auto-cleared when leaving builder mode.
const builderEnteredFromApi = ref(false)
watch(isBuilderMode, (inBuilder) => {
if (!inBuilder) builderEnteredFromApi.value = false
})
// Let i18n rewrite "App" wording to "API" while API mode (or an API builder
// session) is active. Reading the refs inside the checker keeps translations
// reactive to mode changes.
setApiModeChecker(
() => apiMode.value || (isBuilderMode.value && builderEnteredFromApi.value)
)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
@@ -188,6 +234,9 @@ export const useCanvasStore = defineStore('canvas', () => {
rerouteSelected,
appScalePercentage,
linearMode,
apiMode,
apiShowSwagger,
builderEnteredFromApi,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { buildOpenApiSpec } from './buildOpenApiSpec'
interface SpecNode {
[key: string]: SpecNode
}
const asSpec = (spec: Record<string, unknown>) => spec as unknown as SpecNode
describe('buildOpenApiSpec', () => {
it('builds a request schema from typed inputs', () => {
const spec = asSpec(
buildOpenApiSpec({
title: 'My Flow',
inputs: [
{ name: 'text', type: 'STRING', default: 'hello' },
{ name: 'seed', type: 'INT', default: 0, minimum: 0, maximum: 100 },
{
name: 'sampler',
type: 'COMBO',
default: 'euler',
options: ['euler', 'ddim']
}
],
outputs: ['9']
})
)
const schema =
spec.paths['/api/workflow/generate'].post.requestBody.content[
'application/json'
].schema
expect(spec.info.title).toBe('My Flow API')
expect(schema.properties.text).toEqual({ type: 'string', default: 'hello' })
expect(schema.properties.seed).toEqual({
type: 'integer',
minimum: 0,
maximum: 100,
default: 0
})
expect(schema.properties.sampler).toEqual({
type: 'string',
enum: ['euler', 'ddim'],
default: 'euler'
})
})
it('marks inputs without a default as required', () => {
const spec = asSpec(
buildOpenApiSpec({
title: 'Flow',
inputs: [
{ name: 'required_field', type: 'STRING' },
{ name: 'optional_field', type: 'STRING', default: 'x' }
],
outputs: []
})
)
const schema =
spec.paths['/api/workflow/generate'].post.requestBody.content[
'application/json'
].schema
expect(schema.required).toEqual(['required_field'])
})
it('describes selected outputs in the response', () => {
const spec = asSpec(
buildOpenApiSpec({
title: 'Flow',
inputs: [],
outputs: ['9', '12']
})
)
const outputs =
spec.paths['/api/workflow/generate'].post.responses['200'].content[
'application/json'
].schema.properties.outputs
expect(Object.keys(outputs.properties)).toEqual(['9', '12'])
expect(outputs.properties['9'].type).toBe('array')
})
})

View File

@@ -0,0 +1,200 @@
/**
* Build an OpenAPI 3 spec describing a ComfyUI workflow's HTTP API, derived from
* the workflow's App/API configuration (the `linearData` inputs/outputs).
*
* Mirrors the bundled `comfyui_api_mode` custom node: each promoted widget input
* becomes a typed request-body field (with enum/min/max constraints) and the
* selected output nodes shape the response. The generated spec is rendered with
* Swagger UI when the workflow is viewed in API mode.
*/
export type ApiInputType = 'INT' | 'FLOAT' | 'STRING' | 'BOOLEAN' | 'COMBO'
export interface ApiInputSpec {
/** API field name (the promoted widget's display name). */
name: string
type: ApiInputType
default?: unknown
/** Allowed values for COMBO inputs. */
options?: unknown[]
minimum?: number
maximum?: number
/** Where this value lives in the graph, e.g. "node 7.text". */
description?: string
}
export interface BuildOpenApiSpecParams {
title: string
inputs: ApiInputSpec[]
/** Selected output node ids/titles. */
outputs: string[]
}
type JsonSchema = Record<string, unknown>
function inputToSchema(spec: ApiInputSpec): JsonSchema {
const schema: JsonSchema = {}
switch (spec.type) {
case 'INT':
schema.type = 'integer'
break
case 'FLOAT':
schema.type = 'number'
break
case 'BOOLEAN':
schema.type = 'boolean'
break
case 'COMBO':
schema.type = 'string'
if (spec.options && spec.options.length) schema.enum = [...spec.options]
break
case 'STRING':
default:
schema.type = 'string'
break
}
if (spec.minimum !== undefined) schema.minimum = spec.minimum
if (spec.maximum !== undefined) schema.maximum = spec.maximum
if (spec.default !== undefined) schema.default = spec.default
if (spec.description) schema.description = spec.description
return schema
}
const INPUT_DESCRIPTOR_SCHEMA: JsonSchema = {
type: 'object',
properties: {
name: { type: 'string', description: 'API field name.' },
type: {
type: 'string',
enum: ['INT', 'FLOAT', 'STRING', 'BOOLEAN', 'COMBO']
},
value: { description: 'Current value in the workflow.' },
default: { description: 'Default value.' },
options: {
type: 'array',
items: {},
description: 'Allowed values for COMBO inputs.'
},
minimum: { type: 'number' },
maximum: { type: 'number' },
description: {
type: 'string',
description: 'Where this value lives in the graph.'
}
}
}
const OUTPUT_ITEM_SCHEMA: JsonSchema = {
type: 'object',
properties: {
filename: { type: 'string' },
subfolder: { type: 'string' },
type: { type: 'string' },
url: { type: 'string', description: 'URL that streams the generated asset' }
}
}
export function buildOpenApiSpec({
title,
inputs,
outputs
}: BuildOpenApiSpecParams): Record<string, unknown> {
const properties: Record<string, JsonSchema> = {}
const required: string[] = []
for (const input of inputs) {
properties[input.name] = inputToSchema(input)
if (input.default === undefined) required.push(input.name)
}
const requestSchema: JsonSchema = {
type: 'object',
properties
}
if (required.length) requestSchema.required = required
const outputsSchema: JsonSchema =
outputs.length > 0
? {
type: 'object',
description: 'Outputs keyed by source node id.',
properties: Object.fromEntries(
outputs.map((nodeId) => [
nodeId,
{ type: 'array', items: OUTPUT_ITEM_SCHEMA }
])
)
}
: { type: 'object', additionalProperties: true }
return {
openapi: '3.0.3',
info: {
title: `${title} API`,
version: '1.0.0',
description:
'Auto-generated from the workflow API configuration (linearData). ' +
'Run the workflow by POSTing the inputs below.'
},
paths: {
'/api/workflow/inputs': {
get: {
summary: `Describe the "${title}" workflow inputs`,
operationId: 'describeInputs',
tags: [title],
description:
'Returns the workflow inputs (with their current values and ' +
'constraints) and the selected output nodes.',
responses: {
'200': {
description: 'The workflow inputs and their current values.',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
inputs: {
type: 'array',
items: INPUT_DESCRIPTOR_SCHEMA
},
outputs: { type: 'array', items: { type: 'string' } }
}
}
}
}
}
}
}
},
'/api/workflow/generate': {
post: {
summary: `Run the "${title}" workflow`,
operationId: 'generate',
tags: [title],
requestBody: {
required: inputs.length > 0,
content: {
'application/json': { schema: requestSchema }
}
},
responses: {
'200': {
description: 'Workflow executed successfully.',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
prompt_id: { type: 'string' },
outputs: outputsSchema
}
}
}
}
},
'502': { description: 'ComfyUI rejected or failed the prompt.' }
}
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyInputOverrides,
buildOutputAssets,
waitForPromptOutputs
} from './runApiWorkflow'
import type { ApiPrompt } from './runApiWorkflow'
describe('applyInputOverrides', () => {
it('writes payload values onto the mapped node inputs', () => {
const output: ApiPrompt = {
'7': { inputs: { text: 'old' } },
'70': { inputs: { seed: 0 } }
}
applyInputOverrides(
output,
{ prompt: 'hello', seed: 42 },
{
prompt: { nodeId: '7', widgetName: 'text' },
seed: { nodeId: '70', widgetName: 'seed' }
}
)
expect(output['7'].inputs.text).toBe('hello')
expect(output['70'].inputs.seed).toBe(42)
})
it('ignores unknown fields and undefined values', () => {
const output: ApiPrompt = { '7': { inputs: { text: 'keep' } } }
applyInputOverrides(
output,
{ unknown: 'x', text: undefined },
{ text: { nodeId: '7', widgetName: 'text' } }
)
expect(output['7'].inputs.text).toBe('keep')
})
})
describe('buildOutputAssets', () => {
const viewUrl = (filename: string, subfolder: string, type: string) =>
`http://host/view?filename=${filename}&subfolder=${subfolder}&type=${type}`
it('builds asset URLs limited to the selected outputs', () => {
const history = {
'9': { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
'12': {
images: [{ filename: 'b.png', subfolder: 'sub', type: 'output' }]
}
}
const result = buildOutputAssets(history, ['9'], viewUrl)
expect(Object.keys(result)).toEqual(['9'])
expect(result['9'][0]).toEqual({
filename: 'a.png',
subfolder: '',
type: 'output',
url: 'http://host/view?filename=a.png&subfolder=&type=output'
})
})
it('falls back to all output nodes when none are selected', () => {
const history = {
'9': { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
}
const result = buildOutputAssets(history, [], viewUrl)
expect(Object.keys(result)).toEqual(['9'])
})
})
describe('waitForPromptOutputs', () => {
it('returns outputs once the history entry has them', async () => {
const fetchApi = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ 'p-1': { outputs: {} } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
'p-1': { outputs: { '9': { images: [{ filename: 'a.png' }] } } }
})
})
const outputs = await waitForPromptOutputs('p-1', fetchApi, {
intervalMs: 0,
timeoutMs: 1000
})
expect(outputs).toEqual({ '9': { images: [{ filename: 'a.png' }] } })
expect(fetchApi).toHaveBeenCalledWith('/history/p-1')
})
it('throws when the workflow errors', async () => {
const fetchApi = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ 'p-1': { status: { status_str: 'error' } } })
})
await expect(
waitForPromptOutputs('p-1', fetchApi, { intervalMs: 0, timeoutMs: 1000 })
).rejects.toThrow('Workflow execution failed')
})
it('throws on timeout', async () => {
const fetchApi = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({}) })
await expect(
waitForPromptOutputs('p-1', fetchApi, { intervalMs: 0, timeoutMs: 5 })
).rejects.toThrow('Timed out')
})
})

View File

@@ -0,0 +1,120 @@
/**
* Helpers for executing an API-mode workflow from a Swagger "Execute" request.
*
* The Swagger spec is generated from the workflow's `linearData`, so the request
* payload's fields map back onto specific node widgets. To actually run it we:
* 1. take the current graph as an API prompt (`app.graphToPrompt`),
* 2. override the promoted inputs with the request payload values,
* 3. queue it to ComfyUI's `/prompt` endpoint,
* 4. poll `/history/{prompt_id}` until outputs are produced,
* 5. return the outputs with `/view` URLs for the generated assets.
*/
import type { ApiInputFieldTarget } from './useApiSpec'
/** A single API prompt node: `{ inputs, class_type, ... }`. */
type PromptNode = { inputs: Record<string, unknown> }
export type ApiPrompt = Record<string, PromptNode>
export interface ApiOutputAsset {
filename: string
subfolder: string
type: string
/** Absolute URL that streams the generated asset. */
url: string
}
/**
* Apply request payload values onto the API prompt's node inputs, using the
* field→graph mapping derived from `linearData`.
*/
export function applyInputOverrides(
output: ApiPrompt,
payload: Record<string, unknown>,
fieldMap: Record<string, ApiInputFieldTarget>
): void {
for (const [field, value] of Object.entries(payload)) {
if (value === undefined) continue
const target = fieldMap[field]
if (!target) continue
const node = output[target.nodeId]
if (node?.inputs) node.inputs[target.widgetName] = value
}
}
type HistoryOutputs = Record<string, Record<string, unknown>>
/**
* Poll `/history/{prompt_id}` until the prompt has produced outputs, surfacing
* execution errors and a timeout.
*/
export async function waitForPromptOutputs(
promptId: string,
fetchApi: (route: string) => Promise<Response>,
options: { timeoutMs?: number; intervalMs?: number } = {}
): Promise<HistoryOutputs> {
const { timeoutMs = 300_000, intervalMs = 1_000 } = options
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetchApi(`/history/${promptId}`)
if (res.ok) {
const history = (await res.json()) as Record<
string,
{ outputs?: HistoryOutputs; status?: { status_str?: string } }
>
const entry = history?.[promptId]
if (entry?.status?.status_str === 'error') {
throw new Error('Workflow execution failed')
}
if (entry?.outputs && Object.keys(entry.outputs).length > 0) {
return entry.outputs
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
throw new Error('Timed out waiting for the workflow result')
}
/** Output collections that contain file references in a history entry. */
const ASSET_KEYS = ['images', 'gifs', 'audio', 'video'] as const
/**
* Convert history outputs into `{ nodeId: [{ ...asset, url }] }`, limited to the
* selected output nodes (or all output nodes when none are selected).
*/
export function buildOutputAssets(
historyOutputs: HistoryOutputs,
selectedOutputs: string[],
viewUrl: (filename: string, subfolder: string, type: string) => string
): Record<string, ApiOutputAsset[]> {
const nodeIds = selectedOutputs.length
? selectedOutputs
: Object.keys(historyOutputs)
const result: Record<string, ApiOutputAsset[]> = {}
for (const nodeId of nodeIds) {
const nodeOutput = historyOutputs[nodeId]
if (!nodeOutput) continue
const assets: ApiOutputAsset[] = []
for (const key of ASSET_KEYS) {
const items = nodeOutput[key]
if (!Array.isArray(items)) continue
for (const item of items) {
const filename = item?.filename
if (typeof filename !== 'string') continue
const subfolder =
typeof item.subfolder === 'string' ? item.subfolder : ''
const type = typeof item.type === 'string' ? item.type : 'output'
assets.push({
filename,
subfolder,
type,
url: viewUrl(filename, subfolder, type)
})
}
}
if (assets.length) result[nodeId] = assets
}
return result
}

View File

@@ -0,0 +1,142 @@
import { computed } from 'vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import type { ApiInputSpec, ApiInputType } from './buildOpenApiSpec'
import { buildOpenApiSpec } from './buildOpenApiSpec'
interface WidgetOptions {
min?: number
max?: number
step?: number
precision?: number
values?: unknown[]
}
function resolveType(
widget: IBaseWidget,
options: WidgetOptions
): ApiInputType {
const type = String(widget.type ?? '').toLowerCase()
const value = widget.value
if (type === 'toggle' || typeof value === 'boolean') return 'BOOLEAN'
if (type === 'combo' || Array.isArray(options.values)) return 'COMBO'
if (type === 'number' || type === 'slider' || typeof value === 'number') {
const isInt =
options.precision === 0 ||
(Number.isInteger(value as number) &&
(options.step === undefined || Number.isInteger(options.step)))
return isInt ? 'INT' : 'FLOAT'
}
return 'STRING'
}
function widgetToApiInput(
name: string,
selection: Extract<ResolvedSelection, { status: 'resolved' }>
): ApiInputSpec {
const { widget, node } = selection
const options = (widget.options ?? {}) as WidgetOptions
const type = resolveType(widget, options)
return {
name,
type,
default: widget.value,
options: type === 'COMBO' ? (options.values ?? []) : undefined,
minimum: type === 'INT' || type === 'FLOAT' ? options.min : undefined,
maximum: type === 'INT' || type === 'FLOAT' ? options.max : undefined,
description: `node ${String(node.id)}.${widget.name}`
}
}
/**
* Where an API field's value lives in the graph, so request payloads can be
* mapped back onto the workflow's node inputs before execution.
*/
export interface ApiInputFieldTarget {
nodeId: string
widgetName: string
}
type ResolvedInput = Extract<ResolvedSelection, { status: 'resolved' }>
/**
* Compute the API field name for each resolved input, disambiguating duplicate
* display names the same way for both the spec and the request→graph mapping.
*/
function fieldNamesFor(
resolved: ResolvedInput[]
): { fieldName: string; entry: ResolvedInput }[] {
const nameCounts = new Map<string, number>()
for (const entry of resolved) {
nameCounts.set(
entry.displayName,
(nameCounts.get(entry.displayName) ?? 0) + 1
)
}
return resolved.map((entry) => ({
fieldName:
(nameCounts.get(entry.displayName) ?? 0) > 1
? `${String(entry.node.id)}_${entry.displayName}`
: entry.displayName,
entry
}))
}
/**
* Reactively derive an OpenAPI spec from the workflow's API configuration
* (promoted inputs + selected output nodes), for rendering with Swagger UI.
* Also exposes the mapping from API fields/outputs back to the graph so a
* request payload can be executed against the live workflow.
*/
export function useApiSpec() {
const resolvedInputs = useResolvedSelectedInputs()
const appModeStore = useAppModeStore()
const workflowStore = useWorkflowStore()
const resolved = computed<ResolvedInput[]>(() =>
resolvedInputs.value.filter(
(entry): entry is ResolvedInput => entry.status === 'resolved'
)
)
const selectedOutputs = computed(() =>
appModeStore.selectedOutputs.map(String)
)
// The promoted inputs (with current values + constraints) described by the
// GET /api/workflow/inputs endpoint.
const inputs = computed<ApiInputSpec[]>(() =>
fieldNamesFor(resolved.value).map(({ fieldName, entry }) =>
widgetToApiInput(fieldName, entry)
)
)
const spec = computed(() => {
const title = workflowStore.activeWorkflow?.filename ?? 'workflow'
return buildOpenApiSpec({
title,
inputs: inputs.value,
outputs: selectedOutputs.value
})
})
// Maps each API field name to the node + widget it controls.
const inputFieldMap = computed<Record<string, ApiInputFieldTarget>>(() => {
const map: Record<string, ApiInputFieldTarget> = {}
for (const { fieldName, entry } of fieldNamesFor(resolved.value)) {
map[fieldName] = {
nodeId: String(entry.node.id),
widgetName: entry.widget.name
}
}
return map
})
return { spec, inputs, inputFieldMap, selectedOutputs }
}

View File

@@ -5,18 +5,25 @@ import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn()
}
})
const { hasNodes, hasOutputs, enterBuilder, isBuilderMode, canvasState } =
vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn(),
isBuilderMode: ref(false),
canvasState: { apiMode: false, builderEnteredFromApi: false }
}
})
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
useAppMode: () => ({ setMode: vi.fn(), isBuilderMode })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
@@ -53,6 +60,9 @@ describe('LinearWelcome', () => {
beforeEach(() => {
hasNodes.value = false
hasOutputs.value = false
isBuilderMode.value = false
canvasState.apiMode = false
canvasState.builderEnteredFromApi = false
vi.clearAllMocks()
})
@@ -80,4 +90,36 @@ describe('LinearWelcome', () => {
await user.click(screen.getByTestId('linear-welcome-build-app'))
expect(enterBuilder).toHaveBeenCalled()
})
// The test i18n has no messages loaded, so t() returns the key path.
it('shows the app welcome text by default', () => {
renderComponent()
expect(screen.getByText('linearMode.welcome.message')).toBeInTheDocument()
expect(
screen.queryByText('linearMode.welcome.apiMessage')
).not.toBeInTheDocument()
})
it('shows the API welcome text in API mode', () => {
canvasState.apiMode = true
renderComponent()
expect(
screen.getByText('linearMode.welcome.apiMessage')
).toBeInTheDocument()
expect(
screen.getByText('linearMode.welcome.apiSharing')
).toBeInTheDocument()
expect(
screen.queryByText('linearMode.welcome.message')
).not.toBeInTheDocument()
})
it('shows the API welcome text in an API builder session', () => {
isBuilderMode.value = true
canvasState.builderEnteredFromApi = true
renderComponent()
expect(
screen.getByText('linearMode.welcome.apiMessage')
).toBeInTheDocument()
})
})

View File

@@ -5,17 +5,26 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const { t } = useI18n()
const { setMode } = useAppMode()
const { setMode, isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const isAppDefault = computed(
() => workflowStore.activeWorkflow?.initialMode === 'app'
)
// True in API mode or an API builder session, so the welcome text describes the
// generated API/Swagger instead of the app preview.
const isApiContext = computed(
() =>
canvasStore.apiMode ||
(isBuilderMode.value && canvasStore.builderEnteredFromApi)
)
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</script>
@@ -32,9 +41,15 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</div>
<div class="flex max-w-md flex-col gap-3 text-[14px] text-muted-foreground">
<p class="mt-0">{{ t('linearMode.welcome.message') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
<template v-if="isApiContext">
<p class="mt-0">{{ t('linearMode.welcome.apiMessage') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.apiSharing') }}</p>
</template>
<template v-else>
<p class="mt-0">{{ t('linearMode.welcome.message') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
</template>
</div>
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
<p class="mt-0 text-base-foreground">

View File

@@ -46,7 +46,8 @@ export function nodeTypeValidForApp(type: string) {
}
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const canvasStore = useCanvasStore()
const { getCanvas } = canvasStore
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
@@ -231,11 +232,16 @@ export const useAppModeStore = defineStore('appMode', () => {
useSidebarTabStore().activeSidebarTabId = null
setMode(
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'
: 'builder:inputs'
)
// Remember whether this builder session originated from API mode so the UI
// keeps showing "API" wording while building. Captured before changing mode.
const enteredFromApi = mode.value === 'api'
canvasStore.builderEnteredFromApi = enteredFromApi
// App-mode sessions with outputs jump straight to the "arrange" preview
// step. API sessions have no arrange step, so they start at "inputs".
const skipToArrange =
mode.value === 'app' && hasOutputs.value && !enteredFromApi
setMode(skipToArrange ? 'builder:arrange' : 'builder:inputs')
}
function exitBuilder() {

17
src/types/swagger-ui-dist.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
declare module 'swagger-ui-dist' {
export interface SwaggerUIOptions {
domNode?: Element | null
spec?: Record<string, unknown>
url?: string
deepLinking?: boolean
tryItOutEnabled?: boolean
supportedSubmitMethods?: string[]
defaultModelsExpandDepth?: number
[key: string]: unknown
}
export function SwaggerUIBundle(options: SwaggerUIOptions): unknown
export const SwaggerUIStandalonePreset: unknown
export function absolutePath(): string
export function getAbsoluteFSPath(): string
}

View File

@@ -1,6 +1,7 @@
export type AppMode =
| 'graph'
| 'app'
| 'api'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
@@ -19,3 +20,7 @@ export function getWorkflowMode(
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
export function isApiModeValue(mode: AppMode): boolean {
return mode === 'api'
}

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
useTemplateRef,
watch
} from 'vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
applyInputOverrides,
buildOutputAssets,
waitForPromptOutputs
} from '@/renderer/extensions/apiMode/runApiWorkflow'
import type { ApiPrompt } from '@/renderer/extensions/apiMode/runApiWorkflow'
import { useApiSpec } from '@/renderer/extensions/apiMode/useApiSpec'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const { spec, inputs, inputFieldMap, selectedOutputs } = useApiSpec()
const container = useTemplateRef<HTMLDivElement>('container')
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
// The left toolbar's "APIs"/assets buttons toggle a sidebar tab; render its
// panel here so it shows up while viewing the Swagger (as it does in App mode).
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
type SwaggerUIFactory = (options: Record<string, unknown>) => unknown
let swaggerUIBundle: SwaggerUIFactory | undefined
async function ensureSwagger(): Promise<void> {
if (swaggerUIBundle) return
const mod = await import('swagger-ui-dist')
await import('swagger-ui-dist/swagger-ui.css')
swaggerUIBundle = mod.SwaggerUIBundle as SwaggerUIFactory
}
interface SwaggerRequest {
url: string
method: string
headers: Record<string, string>
body?: string
}
interface SwaggerResponse {
url?: string
status: number
statusText?: string
ok: boolean
headers?: Record<string, string>
text?: string
data?: string
body?: unknown
obj?: unknown
}
function absoluteViewUrl(
filename: string,
subfolder: string,
type: string
): string {
const params = new URLSearchParams({ filename, subfolder, type })
return new URL(
api.apiURL(`/view?${params.toString()}`),
window.location.origin
).href
}
/**
* Swagger's "Execute" can't reach the documented endpoint directly, so we
* rewrite the request to ComfyUI's real `/prompt` endpoint, applying the
* payload onto the live workflow's promoted inputs.
*/
async function requestInterceptor(
req: SwaggerRequest
): Promise<SwaggerRequest> {
// The "describe inputs" endpoint is computed entirely on the client from the
// workflow's API configuration. Serve it locally via a data URL so Execute
// returns the inputs and their current values without hitting the server.
if (req.url.includes('/workflow/inputs')) {
const payload = {
inputs: inputs.value,
outputs: selectedOutputs.value
}
req.url =
'data:application/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(payload))
req.method = 'GET'
req.body = undefined
return req
}
if (!req.url.includes('/workflow/generate')) return req
let payload: Record<string, unknown>
try {
payload = req.body ? JSON.parse(req.body) : {}
} catch {
payload = {}
}
const { output, workflow } = await app.graphToPrompt()
applyInputOverrides(
output as unknown as ApiPrompt,
payload,
inputFieldMap.value
)
req.url = api.apiURL('/prompt')
req.method = 'POST'
req.headers = { ...req.headers, 'Content-Type': 'application/json' }
req.body = JSON.stringify({
client_id: api.clientId ?? '',
prompt: output,
extra_data: { extra_pnginfo: { workflow } }
})
return req
}
/**
* After `/prompt` accepts the job, wait for it to finish and replace the
* response with the produced outputs (each asset carries a `/view` URL).
*/
async function responseInterceptor(
res: SwaggerResponse
): Promise<SwaggerResponse> {
if (!res.url || !res.url.endsWith('/prompt')) return res
const setBody = (value: unknown, status = 200, statusText = 'OK') => {
const text = JSON.stringify(value, null, 2)
res.text = text
res.data = text
res.body = value
res.obj = value
res.status = status
res.statusText = statusText
res.ok = status >= 200 && status < 300
res.headers = { ...(res.headers ?? {}), 'content-type': 'application/json' }
}
try {
let parsed: { prompt_id?: string; error?: unknown; node_errors?: unknown }
try {
parsed = (res.body ??
res.obj ??
JSON.parse(res.text ?? '{}')) as typeof parsed
} catch {
return res
}
const promptId = parsed?.prompt_id
if (!promptId) return res // surface node_errors / validation failures as-is
const historyOutputs = await waitForPromptOutputs(promptId, (route) =>
api.fetchApi(route)
)
const outputs = buildOutputAssets(
historyOutputs,
selectedOutputs.value,
absoluteViewUrl
)
setBody({ prompt_id: promptId, outputs })
} catch (error) {
setBody(
{ error: error instanceof Error ? error.message : String(error) },
504,
'Gateway Timeout'
)
}
return res
}
async function render(): Promise<void> {
await ensureSwagger()
if (!container.value || !swaggerUIBundle) return
swaggerUIBundle({
domNode: container.value,
spec: spec.value,
deepLinking: false,
// Enable the "Try it out" / "Execute" buttons so the endpoint can be tested.
tryItOutEnabled: true,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
defaultModelsExpandDepth: 1,
requestInterceptor,
responseInterceptor
})
}
onMounted(() => {
void render()
})
watch(spec, () => {
void render()
})
onBeforeUnmount(() => {
if (container.value) container.value.innerHTML = ''
})
</script>
<template>
<div class="absolute flex size-full flex-col bg-base-background">
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
<div class="flex h-full items-center">
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
<div class="relative flex flex-1 overflow-hidden">
<div
v-if="sidebarOnLeft && activeTab"
class="w-80 shrink-0 overflow-x-hidden overflow-y-auto border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
<div class="relative flex-1 overflow-auto">
<div class="absolute top-2 left-4 z-20">
<AppModeToolbar />
</div>
<div ref="container" class="swagger-api-mode min-h-full pt-16" />
</div>
<div
v-if="!sidebarOnLeft && activeTab"
class="w-80 shrink-0 overflow-x-hidden overflow-y-auto border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
</div>
</div>
</template>
<style scoped>
/*
* Swagger UI ships a light theme with hard-coded dark text, which is illegible
* on the app's dark background. Re-map its colors to the app's theme-aware
* semantic tokens so it stays readable in both light and dark mode.
*/
.swagger-api-mode :deep(.swagger-ui) {
background: transparent;
color: var(--base-foreground);
}
/* Headings, info block and general body text */
.swagger-api-mode :deep(.swagger-ui .info .title),
.swagger-api-mode :deep(.swagger-ui .info li),
.swagger-api-mode :deep(.swagger-ui .info p),
.swagger-api-mode :deep(.swagger-ui .info a),
.swagger-api-mode :deep(.swagger-ui .info table),
.swagger-api-mode :deep(.swagger-ui h1),
.swagger-api-mode :deep(.swagger-ui h2),
.swagger-api-mode :deep(.swagger-ui h3),
.swagger-api-mode :deep(.swagger-ui h4),
.swagger-api-mode :deep(.swagger-ui h5),
.swagger-api-mode :deep(.swagger-ui label),
.swagger-api-mode :deep(.swagger-ui .opblock-tag),
.swagger-api-mode :deep(.swagger-ui .opblock-tag small),
.swagger-api-mode :deep(.swagger-ui .opblock .opblock-summary-path),
.swagger-api-mode :deep(.swagger-ui .opblock .opblock-summary-path__deprecated),
.swagger-api-mode :deep(.swagger-ui .opblock .opblock-summary-description),
.swagger-api-mode :deep(.swagger-ui .opblock-description-wrapper p),
.swagger-api-mode :deep(.swagger-ui .opblock-external-docs-wrapper p),
.swagger-api-mode :deep(.swagger-ui .opblock-title_normal p),
.swagger-api-mode :deep(.swagger-ui .parameter__name),
.swagger-api-mode :deep(.swagger-ui .parameter__type),
.swagger-api-mode :deep(.swagger-ui .parameter__in),
.swagger-api-mode :deep(.swagger-ui table thead tr td),
.swagger-api-mode :deep(.swagger-ui table thead tr th),
.swagger-api-mode :deep(.swagger-ui .response-col_status),
.swagger-api-mode :deep(.swagger-ui .response-col_links),
.swagger-api-mode :deep(.swagger-ui .col_header),
.swagger-api-mode :deep(.swagger-ui .responses-inner h4),
.swagger-api-mode :deep(.swagger-ui .responses-inner h5),
.swagger-api-mode :deep(.swagger-ui .tab li),
.swagger-api-mode :deep(.swagger-ui .tab li button.tablinks),
.swagger-api-mode :deep(.swagger-ui section.models h4),
.swagger-api-mode :deep(.swagger-ui .model-title),
.swagger-api-mode :deep(.swagger-ui .model),
.swagger-api-mode :deep(.swagger-ui .model-toggle),
.swagger-api-mode :deep(.swagger-ui .prop-type),
.swagger-api-mode :deep(.swagger-ui .prop-format) {
color: var(--base-foreground);
}
/* Secondary / muted text */
.swagger-api-mode :deep(.swagger-ui .opblock-tag small),
.swagger-api-mode :deep(.swagger-ui .parameter__in),
.swagger-api-mode :deep(.swagger-ui .renderedMarkdown p),
.swagger-api-mode :deep(.swagger-ui .response-col_description) {
color: var(--muted-foreground);
}
/* Links */
.swagger-api-mode :deep(.swagger-ui a),
.swagger-api-mode :deep(.swagger-ui .info a) {
color: var(--primary-background);
}
/* Section panels, schemes and tables */
.swagger-api-mode :deep(.swagger-ui .scheme-container) {
background: transparent;
box-shadow: none;
}
.swagger-api-mode :deep(.swagger-ui .opblock .opblock-section-header) {
background: var(--secondary-background);
box-shadow: none;
}
.swagger-api-mode :deep(.swagger-ui .opblock) {
background: var(--secondary-background);
border-color: var(--border-subtle);
box-shadow: none;
}
.swagger-api-mode :deep(.swagger-ui .opblock .opblock-summary) {
border-color: var(--border-subtle);
}
.swagger-api-mode :deep(.swagger-ui section.models),
.swagger-api-mode :deep(.swagger-ui section.models .model-container),
.swagger-api-mode :deep(.swagger-ui .model-box) {
background: var(--secondary-background);
border-color: var(--border-subtle);
}
.swagger-api-mode :deep(.swagger-ui table tbody tr td),
.swagger-api-mode :deep(.swagger-ui .responses-inner) {
border-color: var(--border-subtle);
color: var(--base-foreground);
}
/* Form controls */
.swagger-api-mode :deep(.swagger-ui select),
.swagger-api-mode :deep(.swagger-ui input[type='text']),
.swagger-api-mode :deep(.swagger-ui textarea) {
background: var(--base-background);
color: var(--base-foreground);
border-color: var(--border-subtle);
}
/* Inline code / markdown */
.swagger-api-mode :deep(.swagger-ui .markdown code),
.swagger-api-mode :deep(.swagger-ui .renderedMarkdown code) {
background: var(--tertiary-background);
color: var(--base-foreground);
}
/* Outline buttons ("Try it out", "Cancel") have dark text by default */
.swagger-api-mode :deep(.swagger-ui .btn.try-out__btn),
.swagger-api-mode :deep(.swagger-ui .btn.cancel),
.swagger-api-mode :deep(.swagger-ui .btn.btn-clear) {
color: var(--base-foreground);
border-color: var(--border-subtle);
background: transparent;
}
/* Expand/collapse arrows and other icons */
.swagger-api-mode :deep(.swagger-ui .opblock-summary-control svg),
.swagger-api-mode :deep(.swagger-ui .models-control svg),
.swagger-api-mode :deep(.swagger-ui .expand-operation svg),
.swagger-api-mode :deep(.swagger-ui .arrow) {
fill: var(--base-foreground);
}
</style>

218
src/views/ApiView.vue Normal file
View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import type { MaybeElement } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAppModeStore } from '@/stores/appModeStore'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const showLeftBuilder = computed(
() => !sidebarOnLeft.value && isArrangeMode.value
)
const showRightBuilder = computed(
() => sidebarOnLeft.value && isArrangeMode.value
)
const hasLeftPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && activeTab.value) ||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
)
const hasRightPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
(!sidebarOnLeft.value && activeTab.value)
)
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
if (isBuilder) return BUILDER_MIN_SIZE
if (isHidden) return undefined
return SIDEBAR_MIN_SIZE
}
// Remount splitter when panel structure changes so initializePanels()
// properly sets flexBasis for the current set of panels.
const splitterKey = computed(() => {
const left = hasLeftPanel.value ? 'L' : ''
const right = hasRightPanel.value ? 'R' : ''
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
})
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
const rightPanelRef = useTemplateRef<MaybeElement>('rightPanel')
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: leftPanelRef, storageKey: 'Comfy.LinearView.LeftPanelWidth' },
{ ref: rightPanelRef, storageKey: 'Comfy.LinearView.RightPanelWidth' }
],
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
function dragDrop(e: DragEvent) {
const { dataTransfer } = e
if (dataTransfer) linearWorkflowRef.value?.handleDragDrop()
}
</script>
<template>
<MobileDisplay v-if="mobileDisplay" />
<div v-else class="absolute size-full" @dragover.prevent>
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
<div class="flex h-full items-center">
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
<Splitter
:key="splitterKey"
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
@resizestart="$event.originalEvent.preventDefault()"
@resizeend="onResizeEnd"
>
<SplitterPanel
v-if="hasLeftPanel"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
>
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
<LinearControls
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
data-testid="linear-center-panel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"
>
<LinearProgressBar
data-testid="linear-header-progress-bar"
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview
:run-button-click="linearWorkflowRef?.runButtonClick"
:typeform-widget-id="TYPEFORM_WIDGET_ID"
/>
<div class="absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
<div
v-else-if="activeTab"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
</SplitterPanel>
</Splitter>
</div>
</template>
<style scoped>
:deep(.p-splitter-gutter) {
pointer-events: auto;
}
:deep(.p-splitter-gutter:hover),
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
/* Hide gutter next to hidden arrange panels */
:deep(.arrange-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .arrange-panel[style*='display: none']) {
display: none;
}
</style>

View File

@@ -83,7 +83,9 @@ vi.mock('@/renderer/core/canvas/canvasStore', async () => {
const { defineStore } = await import('pinia')
return {
useCanvasStore: defineStore('canvas-test-stub', () => ({
linearMode: ref(false)
linearMode: ref(false),
apiMode: ref(false),
apiShowSwagger: ref(false)
}))
}
})
@@ -164,6 +166,8 @@ vi.mock('@/utils/envUtil', () => ({
const stubModule = { default: { template: '<div />' } }
vi.mock('@/components/graph/GraphCanvas.vue', () => stubModule)
vi.mock('@/views/LinearView.vue', () => stubModule)
vi.mock('@/views/ApiView.vue', () => stubModule)
vi.mock('@/views/ApiSwaggerView.vue', () => stubModule)
vi.mock('@/components/builder/BuilderToolbar.vue', () => stubModule)
vi.mock('@/components/builder/BuilderMenu.vue', () => stubModule)
vi.mock('@/components/builder/BuilderFooterToolbar.vue', () => stubModule)

View File

@@ -5,7 +5,7 @@
<div id="comfyui-body-left" class="comfyui-body-left" />
<div id="comfyui-body-right" class="comfyui-body-right" />
<div
v-show="!linearMode"
v-show="!linearMode && !apiMode"
id="graph-canvas-container"
ref="graphCanvasContainerRef"
class="graph-canvas-container"
@@ -13,6 +13,8 @@
<GraphCanvas @ready="onGraphReady" />
</div>
<LinearView v-if="linearMode" />
<ApiView v-if="apiMode && !apiShowSwagger" />
<ApiSwaggerView v-if="apiMode && apiShowSwagger" />
<template v-if="isBuilderMode">
<BuilderToolbar />
<BuilderMenu />
@@ -97,6 +99,8 @@ import { electronAPI } from '@/utils/envUtil'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
import ApiSwaggerView from '@/views/ApiSwaggerView.vue'
import ApiView from '@/views/ApiView.vue'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
@@ -112,7 +116,7 @@ const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const { isBuilderMode, mode, isAppMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
const { linearMode, apiMode, apiShowSwagger } = storeToRefs(useCanvasStore())
watch(linearMode, (isLinear) => {
if (isLinear) {
@@ -120,6 +124,12 @@ watch(linearMode, (isLinear) => {
}
})
watch(apiMode, (isApi) => {
if (isApi) {
useSidebarTabStore().activeSidebarTabId = null
}
})
const telemetry = useTelemetry()
const authStore = useAuthStore()
let hasTrackedLogin = false