mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-01 20:17:31 +00:00
Compare commits
2 Commits
shihchi/re
...
alexis/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f0af9a13 | ||
|
|
0adcfb32e6 |
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
37
src/i18n.ts
37
src/i18n.ts
@@ -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(),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
src/renderer/extensions/apiMode/buildOpenApiSpec.test.ts
Normal file
87
src/renderer/extensions/apiMode/buildOpenApiSpec.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
200
src/renderer/extensions/apiMode/buildOpenApiSpec.ts
Normal file
200
src/renderer/extensions/apiMode/buildOpenApiSpec.ts
Normal 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.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/renderer/extensions/apiMode/runApiWorkflow.test.ts
Normal file
110
src/renderer/extensions/apiMode/runApiWorkflow.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
120
src/renderer/extensions/apiMode/runApiWorkflow.ts
Normal file
120
src/renderer/extensions/apiMode/runApiWorkflow.ts
Normal 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
|
||||
}
|
||||
142
src/renderer/extensions/apiMode/useApiSpec.ts
Normal file
142
src/renderer/extensions/apiMode/useApiSpec.ts
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
17
src/types/swagger-ui-dist.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
372
src/views/ApiSwaggerView.vue
Normal file
372
src/views/ApiSwaggerView.vue
Normal 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
218
src/views/ApiView.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user