Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
ef478f1cdc refactor: thread validated state through subgraph save 2026-06-30 22:40:39 -07:00
60 changed files with 151 additions and 3229 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET_PX,
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
>
<slot />
</li>

View File

@@ -1,45 +0,0 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,10 +34,6 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -96,19 +92,13 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId(TestIds.linear.runButton)
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,9 +172,6 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -1,106 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
)
.toBe(1)
})

View File

@@ -3,7 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

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

95
pnpm-lock.yaml generated
View File

@@ -324,9 +324,6 @@ catalogs:
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
shiki:
specifier: ^4.3.0
version: 4.3.0
storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -612,9 +609,6 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
shiki:
specifier: 'catalog:'
version: 4.3.0
three:
specifier: 'catalog:'
version: 0.184.0
@@ -3471,58 +3465,30 @@ packages:
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
engines: {node: '>=20'}
'@shikijs/core@4.3.0':
resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.3.0':
resolution: {integrity: sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.3.0':
resolution: {integrity: sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==}
engines: {node: '>=20'}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
engines: {node: '>=20'}
'@shikijs/langs@4.3.0':
resolution: {integrity: sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==}
engines: {node: '>=20'}
'@shikijs/primitive@4.1.0':
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.0':
resolution: {integrity: sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==}
engines: {node: '>=20'}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
engines: {node: '>=20'}
'@shikijs/themes@4.3.0':
resolution: {integrity: sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==}
engines: {node: '>=20'}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
engines: {node: '>=20'}
'@shikijs/types@4.3.0':
resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -7956,10 +7922,6 @@ packages:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
shiki@4.3.0:
resolution: {integrity: sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -8922,8 +8884,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -11574,74 +11536,36 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@4.3.0':
dependencies:
'@shikijs/primitive': 4.3.0
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-javascript@4.3.0':
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@4.3.0':
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/langs@4.3.0':
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/primitive@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/primitive@4.3.0':
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/themes@4.3.0':
dependencies:
'@shikijs/types': 4.3.0
'@shikijs/types@4.1.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@4.3.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sparkjsdev/spark@2.1.0(three@0.184.0)':
@@ -11744,7 +11668,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.6
vue-component-type-helpers: 3.3.5
'@swc/helpers@0.5.21':
dependencies:
@@ -17083,17 +17007,6 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shiki@4.3.0:
dependencies:
'@shikijs/core': 4.3.0
'@shikijs/engine-javascript': 4.3.0
'@shikijs/engine-oniguruma': 4.3.0
'@shikijs/langs': 4.3.0
'@shikijs/themes': 4.3.0
'@shikijs/types': 4.3.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -18225,7 +18138,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.6: {}
vue-component-type-helpers@3.3.5: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -117,7 +117,6 @@ catalog:
primevue: ^4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
shiki: ^4.3.0
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import { ref, watchEffect } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { HighlightLanguage } from './codeHighlighter'
import { highlightCode } from './codeHighlighter'
const { code, language } = defineProps<{
code: string
language: HighlightLanguage
}>()
const colorPaletteStore = useColorPaletteStore()
const html = ref('')
watchEffect(async (onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
const rendered = await highlightCode(
code,
language,
!!colorPaletteStore.completedActivePalette.light_theme
)
if (!cancelled) html.value = DOMPurify.sanitize(rendered)
})
</script>
<template>
<div class="overflow-hidden rounded-lg bg-secondary-background">
<div
v-if="html"
class="[&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-xs/relaxed"
v-html="html"
/>
<pre
v-else
class="m-0 overflow-x-auto p-4 font-mono text-xs/relaxed"
v-text="code"
/>
</div>
</template>

View File

@@ -1,157 +0,0 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useAppModeStore } from '@/stores/appModeStore'
import ApiEndpointsSection from './ApiEndpointsSection.vue'
import ApiParametersSection from './ApiParametersSection.vue'
import ApiResponseSection from './ApiResponseSection.vue'
import ApiSnippetsCard from './ApiSnippetsCard.vue'
import ApiTestPanel from './ApiTestPanel.vue'
import { buildLlmSummary } from './llmSummary'
import { buildOpenApiDocument } from './openApiDocument'
import { useApiSpec } from './useApiSpec'
const { t } = useI18n()
const router = useRouter()
const appModeStore = useAppModeStore()
const { copyToClipboard } = useCopyToClipboard()
const spec = useApiSpec()
const API_DOCS_URL = 'https://docs.comfy.org'
function onOpenApiDocs() {
window.open(API_DOCS_URL, '_blank', 'noopener')
}
async function onOpenLogs() {
await router.push({ name: 'ApiLogsView' })
}
async function onCopySchema() {
await copyToClipboard(
JSON.stringify(buildOpenApiDocument(spec.value), null, 2)
)
}
async function onCopyLlmSummary() {
await copyToClipboard(buildLlmSummary(spec.value))
}
function onOpenLlmSummary() {
const blob = new Blob([buildLlmSummary(spec.value)], {
type: 'text/plain;charset=utf-8'
})
window.open(URL.createObjectURL(blob), '_blank')
}
</script>
<template>
<div class="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<header class="flex flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<h1 class="m-0 truncate text-2xl font-bold text-base-foreground">
{{ spec.title }}
</h1>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ t('apiBuilder.pageSubtitle') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="lg" @click="onCopySchema">
<i class="icon-[lucide--file-json]" aria-hidden="true" />
{{ t('apiBuilder.copySchema') }}
</Button>
<ButtonGroup class="rounded-lg">
<Button variant="secondary" size="lg" @click="onCopyLlmSummary">
<i class="icon-[lucide--file-text]" aria-hidden="true" />
{{ t('apiBuilder.llmsLabel') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="lg"
:aria-label="t('apiBuilder.llmsMenu')"
class="w-7 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="onOpenLlmSummary">
<Button
variant="textonly"
size="lg"
class="w-full justify-start font-normal"
>
<i
class="icon-[lucide--external-link]"
aria-hidden="true"
/>
{{ t('apiBuilder.openMarkdown') }}
</Button>
</DropdownMenuItem>
<DropdownMenuItem as-child @select="onCopyLlmSummary">
<Button
variant="textonly"
size="lg"
class="w-full justify-start font-normal"
>
<i class="icon-[lucide--copy]" aria-hidden="true" />
{{ t('apiBuilder.copyMarkdown') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button variant="secondary" size="lg" @click="onOpenApiDocs">
<i class="icon-[lucide--book-open]" aria-hidden="true" />
{{ t('apiBuilder.apiDocs') }}
</Button>
<Button variant="secondary" size="lg" @click="onOpenLogs">
<i class="icon-[lucide--scroll-text]" aria-hidden="true" />
{{ t('apiBuilder.logs') }}
</Button>
<Button
variant="inverted"
size="lg"
@click="appModeStore.enterBuilder('api')"
>
<i class="icon-[lucide--hammer]" aria-hidden="true" />
{{ t('apiBuilder.editApi') }}
</Button>
</div>
</header>
<div class="flex flex-col items-start gap-6 lg:flex-row">
<div class="flex w-full min-w-0 flex-1 flex-col gap-6">
<ApiEndpointsSection :spec />
<ApiParametersSection :parameters="spec.parameters" />
<ApiSnippetsCard :spec />
<ApiResponseSection :spec />
</div>
<ApiTestPanel class="top-6 w-full shrink-0 lg:sticky lg:w-96" />
</div>
</div>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
const { title, description } = defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section
class="rounded-2xl border border-border-subtle bg-base-background p-5"
>
<h2 class="m-0 text-base font-semibold">{{ title }}</h2>
<p v-if="description" class="mt-1 mb-0 text-sm text-muted-foreground">
{{ description }}
</p>
<div class="mt-4">
<slot />
</div>
</section>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ApiSpec } from './apiSpec'
import ApiDocsSection from './ApiDocsSection.vue'
const { spec } = defineProps<{ spec: ApiSpec }>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const endpoints = computed(() => [
{
method: 'POST',
url: spec.submitUrl,
label: t('apiBuilder.submitEndpoint')
},
{
method: 'GET',
url: spec.jobUrl,
label: t('apiBuilder.pollEndpoint')
}
])
</script>
<template>
<ApiDocsSection
:title="t('apiBuilder.endpointsTitle')"
:description="t('apiBuilder.endpointsDescription')"
>
<ul class="m-0 flex list-none flex-col gap-2 p-0">
<li
v-for="endpoint in endpoints"
:key="endpoint.url"
class="flex items-center gap-3 rounded-lg bg-secondary-background px-3 py-2"
>
<span
class="rounded-md bg-primary-background/20 px-2 py-0.5 font-mono text-xs font-bold text-primary-background"
>
{{ endpoint.method }}
</span>
<code class="min-w-0 flex-1 truncate font-mono text-sm">
{{ endpoint.url }}
</code>
<span class="text-xs whitespace-nowrap text-muted-foreground">
{{ endpoint.label }}
</span>
<Button
variant="textonly"
size="icon"
:aria-label="t('g.copy')"
@click="copyToClipboard(endpoint.url)"
>
<i class="icon-[lucide--copy]" />
</Button>
</li>
</ul>
</ApiDocsSection>
</template>

View File

@@ -1,106 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ApiParameter } from './apiSpec'
import { isMediaParameter } from './apiSpec'
import ApiDocsSection from './ApiDocsSection.vue'
const { parameters } = defineProps<{ parameters: ApiParameter[] }>()
const { t } = useI18n()
const MAX_ENUM_PREVIEW = 6
function constraintEntries(
parameter: ApiParameter
): { label: string; value: string }[] {
const entries: { label: string; value: string }[] = []
if (isMediaParameter(parameter)) {
entries.push({
label: t('apiBuilder.acceptsLabel'),
value: t('apiBuilder.mediaAccepts')
})
return entries
}
if (parameter.defaultValue !== undefined) {
entries.push({
label: t('apiBuilder.defaultLabel'),
value: JSON.stringify(parameter.defaultValue)
})
}
if (parameter.minimum !== undefined) {
entries.push({
label: t('apiBuilder.minLabel'),
value: String(parameter.minimum)
})
}
if (parameter.maximum !== undefined) {
entries.push({
label: t('apiBuilder.maxLabel'),
value: String(parameter.maximum)
})
}
if (parameter.enumValues?.length) {
const preview = parameter.enumValues.slice(0, MAX_ENUM_PREVIEW).join(', ')
const overflow = parameter.enumValues.length - MAX_ENUM_PREVIEW
entries.push({
label: t('apiBuilder.optionsLabel'),
value:
overflow > 0
? t('apiBuilder.optionsOverflow', { preview, count: overflow })
: preview
})
}
return entries
}
</script>
<template>
<ApiDocsSection
:title="t('apiBuilder.parametersTitle')"
:description="t('apiBuilder.parametersDescription')"
>
<p v-if="!parameters.length" class="m-0 text-sm text-muted-foreground">
{{ t('apiBuilder.parametersEmpty') }}
</p>
<ul v-else class="m-0 flex list-none flex-col p-0">
<li
v-for="parameter in parameters"
:key="parameter.name"
class="flex flex-col gap-1 border-b border-border-subtle py-3 first:pt-0 last:border-b-0 last:pb-0"
>
<div class="flex flex-wrap items-center gap-2">
<code class="font-mono text-sm font-semibold">
{{ parameter.name }}
</code>
<span
class="rounded-md bg-secondary-background px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
>
{{ parameter.type }}
</span>
<span
v-if="parameter.required"
class="rounded-md bg-warning-background/20 px-1.5 py-0.5 text-xs text-warning-background"
>
{{ t('apiBuilder.requiredLabel') }}
</span>
<span class="ml-auto truncate text-xs text-muted-foreground">
{{ parameter.nodeTitle }}
</span>
</div>
<div
v-if="constraintEntries(parameter).length"
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"
>
<span
v-for="entry in constraintEntries(parameter)"
:key="entry.label"
>
{{ entry.label }}:
<code class="font-mono">{{ entry.value }}</code>
</span>
</div>
</li>
</ul>
</ApiDocsSection>
</template>

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiCodeBlock from './ApiCodeBlock.vue'
import type { ApiSpec } from './apiSpec'
import { exampleResponseBody } from './apiSpec'
import ApiDocsSection from './ApiDocsSection.vue'
const { spec } = defineProps<{ spec: ApiSpec }>()
const { t } = useI18n()
const exampleResponse = computed(() =>
JSON.stringify(exampleResponseBody(spec), null, 2)
)
</script>
<template>
<ApiDocsSection
:title="t('apiBuilder.responseTitle')"
:description="t('apiBuilder.responseDescription')"
>
<div class="flex flex-col gap-4">
<div>
<h3 class="m-0 text-xs font-medium text-muted-foreground uppercase">
{{ t('apiBuilder.outputsLabel') }}
</h3>
<ul class="m-0 mt-2 flex list-none flex-col gap-2 p-0">
<li
v-for="output in spec.outputs"
:key="output.key"
class="flex items-center gap-2"
>
<i class="icon-[lucide--image] size-4 text-muted-foreground" />
<code
class="rounded-md bg-secondary-background px-1.5 py-0.5 font-mono text-xs"
>
{{ output.key }}
</code>
</li>
</ul>
</div>
<div>
<h3 class="m-0 text-xs font-medium text-muted-foreground uppercase">
{{ t('apiBuilder.exampleResponse') }}
</h3>
<ApiCodeBlock class="mt-2" :code="exampleResponse" language="json" />
</div>
</div>
</ApiDocsSection>
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { cn } from '@comfyorg/tailwind-utils'
import ApiCodeBlock from './ApiCodeBlock.vue'
import type { ApiSpec } from './apiSpec'
import type { SnippetLanguage } from './apiSnippets'
import { SNIPPET_LANGUAGES, buildSnippet } from './apiSnippets'
import type { HighlightLanguage } from './codeHighlighter'
const { spec } = defineProps<{ spec: ApiSpec }>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const LANGUAGE_LABELS: Record<SnippetLanguage, string> = {
curl: 'cURL',
javascript: 'JavaScript',
python: 'Python'
}
const HIGHLIGHT_LANGUAGES: Record<SnippetLanguage, HighlightLanguage> = {
curl: 'bash',
javascript: 'javascript',
python: 'python'
}
const activeLanguage = ref<SnippetLanguage>(SNIPPET_LANGUAGES[0])
const code = computed(() => buildSnippet(activeLanguage.value, spec))
</script>
<template>
<section
class="rounded-2xl border border-border-subtle bg-base-background p-5"
>
<div class="flex flex-wrap items-center gap-3">
<h2 class="m-0 min-w-0 flex-1 text-base font-semibold">
{{ t('apiBuilder.snippetsTitle') }}
</h2>
<div
class="flex items-center gap-1 rounded-lg bg-secondary-background p-1"
role="tablist"
:aria-label="t('apiBuilder.snippetsTitle')"
>
<Button
v-for="language in SNIPPET_LANGUAGES"
:key="language"
variant="textonly"
size="sm"
role="tab"
:aria-selected="activeLanguage === language"
:class="
cn(
'font-mono',
activeLanguage === language &&
'bg-base-background hover:bg-base-background'
)
"
@click="activeLanguage = language"
>
{{ LANGUAGE_LABELS[language] }}
</Button>
</div>
</div>
<div class="relative mt-4">
<ApiCodeBlock :code :language="HIGHLIGHT_LANGUAGES[activeLanguage]" />
<Button
variant="textonly"
size="icon"
class="absolute top-2 right-2"
:aria-label="t('g.copy')"
@click="copyToClipboard(code)"
>
<i class="icon-[lucide--copy]" />
</Button>
</div>
</section>
</template>

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import Loader from '@/components/loader/Loader.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { isActiveSubscription } = useBillingContext()
const { selectedInputs } = storeToRefs(useAppModeStore())
const { outputs, isWorkflowActive } = useOutputHistory()
const isQueueing = ref(false)
const latestAsset = computed(() => outputs.media.value[0])
async function run() {
isQueueing.value = true
try {
await commandStore.execute('Comfy.QueuePrompt', {
metadata: { subscribe_to_run: false, trigger_source: 'api_builder' }
})
} finally {
isQueueing.value = false
}
}
</script>
<template>
<aside
class="flex flex-col overflow-hidden rounded-2xl border border-border-subtle bg-base-background"
:aria-label="t('apiBuilder.testTitle')"
>
<div class="border-b border-border-subtle p-4">
<h2 class="m-0 text-base font-semibold">
{{ t('apiBuilder.testTitle') }}
</h2>
<p class="mt-1 mb-0 text-xs text-muted-foreground">
{{ t('apiBuilder.testDescription') }}
</p>
</div>
<div class="max-h-96 overflow-y-auto p-2">
<AppModeWidgetList />
<p
v-if="!selectedInputs.length"
class="m-0 p-4 text-center text-sm text-muted-foreground"
>
{{ t('apiBuilder.testNoInputs') }}
</p>
</div>
<div class="border-t border-border-subtle p-4">
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full" />
<Button
v-else
variant="primary"
size="lg"
class="w-full"
:disabled="isQueueing"
@click="run"
>
<i class="icon-[lucide--play]" aria-hidden="true" />
{{ t('menu.run') }}
</Button>
<div class="mt-4">
<h3 class="m-0 text-xs font-medium text-muted-foreground uppercase">
{{ t('apiBuilder.latestResult') }}
</h3>
<div
class="mt-2 flex min-h-40 items-center justify-center overflow-hidden rounded-lg bg-secondary-background"
>
<Loader v-if="isWorkflowActive" size="sm" />
<img
v-else-if="latestAsset?.preview_url"
:src="latestAsset.preview_url"
:alt="latestAsset.name"
class="max-h-64 w-full object-contain"
/>
<span
v-else
class="p-4 text-center text-xs text-muted-foreground"
v-text="t('apiBuilder.noResults')"
/>
</div>
</div>
</div>
</aside>
</template>

View File

@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildApiSpec } from './apiSpec'
import { SNIPPET_LANGUAGES, buildSnippet } from './apiSnippets'
const spec = buildApiSpec({
title: 'Portrait Maker',
workflowId: 'wf_test1',
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a "cat"' },
{ displayName: 'Tiled', widgetType: 'toggle', value: true }
],
outputs: [{ nodeId: '9', title: 'Save Image' }]
})
describe('buildSnippet', () => {
it.for(SNIPPET_LANGUAGES)(
'%s snippet includes all parameters',
(language) => {
const snippet = buildSnippet(language, spec)
expect(snippet).toContain('prompt')
expect(snippet).toContain('tiled')
}
)
it('curl targets both raw REST endpoints with a valid JSON body', () => {
const snippet = buildSnippet('curl', spec)
expect(snippet).toContain(spec.submitUrl)
expect(snippet).toContain('/jobs/')
const body = snippet.match(/-d '([\s\S]*?)'/)?.[1]
expect(body).toBeDefined()
expect(JSON.parse(body!)).toEqual({ prompt: 'a "cat"', tiled: true })
})
it('javascript uses the Comfy client subscribed to the workflow id', () => {
const snippet = buildSnippet('javascript', spec)
expect(snippet).toContain('@comfyorg/client')
expect(snippet).toContain("comfy.subscribe('wf_test1'")
})
it('python uses the Comfy client with python literals', () => {
const snippet = buildSnippet('python', spec)
expect(snippet).toContain('comfy_client.subscribe(')
expect(snippet).toContain('"wf_test1"')
expect(snippet).toContain('"tiled": True')
expect(snippet).not.toContain('true')
})
it.for(SNIPPET_LANGUAGES)(
'%s snippet mentions the optional webhook',
(language) => {
expect(buildSnippet(language, spec).toLowerCase()).toContain('webhook')
}
)
it('shows the upload helper only when a media input exists', () => {
const mediaSpec = buildApiSpec({
title: 'Editor',
workflowId: 'wf_media',
parameters: [
{
displayName: 'Image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png'] }
}
],
outputs: []
})
expect(buildSnippet('javascript', mediaSpec)).toContain('storage.upload')
expect(buildSnippet('python', mediaSpec)).toContain('upload_file')
expect(buildSnippet('javascript', spec)).not.toContain('storage.upload')
expect(buildSnippet('python', spec)).not.toContain('upload_file')
})
})

View File

@@ -1,99 +0,0 @@
import type { ApiSpec } from './apiSpec'
import { exampleRequestBody, isMediaParameter } from './apiSpec'
export const SNIPPET_LANGUAGES = ['javascript', 'python', 'curl'] as const
export type SnippetLanguage = (typeof SNIPPET_LANGUAGES)[number]
function indentBlock(text: string, spaces: number): string {
const pad = ' '.repeat(spaces)
return text
.split('\n')
.map((line, index) => (index === 0 ? line : pad + line))
.join('\n')
}
function jsonBody(spec: ApiSpec): string {
return JSON.stringify(exampleRequestBody(spec), null, 2)
}
function pythonLiteral(value: string | number | boolean): string {
if (typeof value === 'boolean') return value ? 'True' : 'False'
return JSON.stringify(value)
}
function pythonBody(spec: ApiSpec): string {
const entries = Object.entries(exampleRequestBody(spec))
if (!entries.length) return '{}'
const lines = entries.map(
([name, value]) => ` ${JSON.stringify(name)}: ${pythonLiteral(value)},`
)
return `{\n${lines.join('\n')}\n}`
}
function hasMediaInput(spec: ApiSpec): boolean {
return spec.parameters.some(isMediaParameter)
}
function curlSnippet(spec: ApiSpec): string {
return `# Submit a job
# Optional: add "webhook_url": "https://your.app/comfy-webhook" to the body
# to be called back on completion instead of polling.
curl -X POST "${spec.submitUrl}" \\
-H "Authorization: Bearer $COMFY_API_KEY" \\
-H "Content-Type: application/json" \\
-d '${jsonBody(spec)}'
# Poll for the result (response includes job_id)
curl "${spec.jobUrl}" \\
-H "Authorization: Bearer $COMFY_API_KEY"`
}
function javascriptSnippet(spec: ApiSpec): string {
const uploadHint = hasMediaInput(spec)
? `// Optional: upload a local file and use the returned URL as a media input
// const imageUrl = await comfy.storage.upload(file)
`
: ''
return `// npm install @comfyorg/client — reads COMFY_API_KEY from your environment
import { comfy } from '@comfyorg/client'
${uploadHint}const result = await comfy.subscribe('${spec.workflowId}', {
input: ${indentBlock(jsonBody(spec), 2)},
onQueueUpdate: (update) => console.log(update.status)
// webhookUrl: 'https://your.app/comfy-webhook' — optional completion callback
})
console.log(result.outputs)`
}
function pythonSnippet(spec: ApiSpec): string {
const uploadHint = hasMediaInput(spec)
? `# Optional: upload a local file and use the returned URL as a media input
# image_url = comfy_client.upload_file("input.png")
`
: ''
return `# pip install comfy-client — reads COMFY_API_KEY from your environment
import comfy_client
${uploadHint}result = comfy_client.subscribe(
"${spec.workflowId}",
arguments=${indentBlock(pythonBody(spec), 4)},
# webhook_url="https://your.app/comfy-webhook", # optional completion callback
)
print(result["outputs"])`
}
export function buildSnippet(language: SnippetLanguage, spec: ApiSpec): string {
switch (language) {
case 'curl':
return curlSnippet(spec)
case 'javascript':
return javascriptSnippet(spec)
case 'python':
return pythonSnippet(spec)
}
}

View File

@@ -1,230 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ApiSpecSource } from './apiSpec'
import { buildApiSpec, deriveWorkflowId, exampleRequestBody } from './apiSpec'
function specFrom(partial: Partial<ApiSpecSource>) {
return buildApiSpec({
title: 'My Workflow',
workflowId: 'wf_test1',
parameters: [],
outputs: [],
...partial
})
}
describe('deriveWorkflowId', () => {
it('is deterministic and prefixed for a given seed', () => {
const id = deriveWorkflowId('workflows/api-test.json')
expect(id).toBe(deriveWorkflowId('workflows/api-test.json'))
expect(id).toMatch(/^wf_[a-z0-9]+$/)
})
it('differs for different seeds', () => {
expect(deriveWorkflowId('workflows/a.json')).not.toBe(
deriveWorkflowId('workflows/b.json')
)
})
})
describe('buildApiSpec', () => {
it('builds the submit URL from the workflow id, not the title', () => {
const spec = specFrom({ title: 'My Cool Workflow (v2)!' })
expect(spec.submitUrl).toContain('/workflows/wf_test1/run')
expect(spec.submitUrl).not.toContain('cool')
})
it('maps widget types to parameter types', () => {
const spec = specFrom({
parameters: [
{
displayName: 'Steps',
widgetType: 'number',
value: 20,
options: { precision: 0, min: 1, max: 100 }
},
{
displayName: 'CFG',
widgetType: 'slider',
value: 7.5,
options: { min: 0, max: 20 }
},
{
displayName: 'Sampler',
widgetType: 'combo',
value: 'euler',
options: { values: ['euler', 'ddim'] }
},
{ displayName: 'Tiled', widgetType: 'toggle', value: true },
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a cat' }
]
})
expect(spec.parameters.map((p) => p.type)).toEqual([
'integer',
'number',
'string',
'boolean',
'string'
])
expect(spec.parameters[0]).toMatchObject({
name: 'steps',
minimum: 1,
maximum: 100,
defaultValue: 20,
required: false
})
expect(spec.parameters[2].enumValues).toEqual(['euler', 'ddim'])
})
it('types media upload widgets as required media parameters, ignoring the file-list enum', () => {
const spec = specFrom({
parameters: [
{
displayName: 'image',
nodeTitle: 'Load Image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png', 'other.png'] }
}
]
})
expect(spec.parameters[0]).toMatchObject({
name: 'image',
type: 'image',
required: true
})
expect(spec.parameters[0].enumValues).toBeUndefined()
expect(spec.parameters[0].defaultValue).toBeUndefined()
})
it('types 3D model upload widgets as mesh parameters', () => {
const spec = specFrom({
parameters: [
{
displayName: 'model_file',
nodeTitle: 'Load 3D',
widgetType: 'combo',
value: 'chair.glb',
mediaKind: 'mesh',
options: { values: ['chair.glb', 'table.obj'] }
}
]
})
expect(spec.parameters[0]).toMatchObject({
name: 'model_file',
type: 'mesh',
required: true
})
expect(spec.parameters[0].enumValues).toBeUndefined()
})
it('marks parameters without a primitive value as required', () => {
const spec = specFrom({
parameters: [
{ displayName: 'Image', widgetType: 'image', value: undefined }
]
})
expect(spec.parameters[0].required).toBe(true)
expect(spec.parameters[0].defaultValue).toBeUndefined()
})
it('keys outputs by a readable slug of their title, deduping collisions', () => {
const spec = specFrom({
outputs: [
{ nodeId: '9', title: 'Save Image' },
{ nodeId: '12', title: 'Save Image' },
{ nodeId: '15', title: 'Preview (Final)' }
]
})
expect(spec.outputs.map((o) => o.key)).toEqual([
'save_image',
'save_image_2',
'preview_final'
])
})
it('dedupes colliding parameter names', () => {
const spec = specFrom({
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a' },
{ displayName: 'prompt', widgetType: 'customtext', value: 'b' },
{ displayName: 'Prompt!', widgetType: 'customtext', value: 'c' }
]
})
expect(spec.parameters.map((p) => p.name)).toEqual([
'prompt',
'prompt_2',
'prompt_3'
])
})
})
describe('exampleRequestBody', () => {
it('uses defaults and typed placeholders for missing values', () => {
const spec = specFrom({
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a cat' },
{
displayName: 'Seed',
widgetType: 'number',
value: undefined,
options: { precision: 0, min: 1 }
},
{
displayName: 'Sampler',
widgetType: 'combo',
value: undefined,
options: { values: ['euler', 'ddim'] }
}
]
})
expect(exampleRequestBody(spec)).toEqual({
prompt: 'a cat',
seed: 1,
sampler: 'euler'
})
})
it('includes only required parameters when requiredOnly is set', () => {
const spec = specFrom({
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a cat' },
{
displayName: 'Image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png'] }
}
]
})
expect(exampleRequestBody(spec, true)).toEqual({
image: 'https://example.com/input.png'
})
})
it('uses a URL placeholder for media parameters instead of a gallery filename', () => {
const spec = specFrom({
parameters: [
{
displayName: 'image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png'] }
}
]
})
expect(exampleRequestBody(spec)).toEqual({
image: 'https://example.com/input.png'
})
})
})

View File

@@ -1,231 +0,0 @@
export const API_BASE_URL = 'https://api.comfy.org/v1'
export type MediaKind = 'image' | 'video' | 'audio' | 'mesh'
type ApiParameterType = 'integer' | 'number' | 'string' | 'boolean' | MediaKind
export interface ApiParameter {
name: string
displayName: string
nodeTitle?: string
type: ApiParameterType
required: boolean
defaultValue?: string | number | boolean
minimum?: number
maximum?: number
enumValues?: string[]
}
interface OutputSource {
nodeId: string
title: string
}
interface ApiOutput extends OutputSource {
/** Key of this output in the response `outputs` object. */
key: string
}
export interface ApiSpec {
title: string
workflowId: string
submitUrl: string
jobUrl: string
parameters: ApiParameter[]
outputs: ApiOutput[]
}
export interface ParameterSource {
displayName: string
nodeTitle?: string
widgetType: string
value: unknown
mediaKind?: MediaKind
options?: {
min?: number
max?: number
precision?: number
values?: unknown
}
}
export interface ApiSpecSource {
title: string
workflowId: string
parameters: ParameterSource[]
outputs: OutputSource[]
}
// Placeholder identity until the backend assigns real workflow IDs on
// publish: a stable hash of the workflow path, so the URL survives renders
// but demonstrates the opaque-ID contract.
export function deriveWorkflowId(seed: string): string {
let hash = 5381
for (let index = 0; index < seed.length; index++) {
hash = (hash * 33) ^ seed.charCodeAt(index)
}
return `wf_${(hash >>> 0).toString(36)}`
}
function parameterName(displayName: string): string {
const name = displayName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
return name || 'input'
}
function parameterType(source: ParameterSource): ApiParameterType {
switch (source.widgetType) {
case 'toggle':
return 'boolean'
case 'number':
case 'slider':
return source.options?.precision === 0 ? 'integer' : 'number'
default:
return 'string'
}
}
function parameterDefault(
value: unknown
): string | number | boolean | undefined {
const isPrimitive =
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
return isPrimitive ? value : undefined
}
function enumValues(source: ParameterSource): string[] | undefined {
const { values } = source.options ?? {}
if (!Array.isArray(values) || !values.length) return undefined
return values.map(String)
}
function claimUniqueName(name: string, taken: Set<string>): string {
let unique = name
if (taken.has(name)) {
let suffix = 2
while (taken.has(`${name}_${suffix}`)) suffix += 1
unique = `${name}_${suffix}`
}
taken.add(unique)
return unique
}
function buildParameter(
source: ParameterSource,
taken: Set<string>
): ApiParameter {
const name = claimUniqueName(parameterName(source.displayName), taken)
// Media widgets are combos over the server's current file list; those
// filenames mean nothing to an API caller, so the parameter is a required
// upload with no default and no enum.
if (source.mediaKind) {
return {
name,
displayName: source.displayName,
nodeTitle: source.nodeTitle,
type: source.mediaKind,
required: true
}
}
const type = parameterType(source)
const defaultValue = parameterDefault(source.value)
return {
name,
displayName: source.displayName,
nodeTitle: source.nodeTitle,
type,
required: defaultValue === undefined,
defaultValue,
minimum: source.options?.min,
maximum: source.options?.max,
enumValues: type === 'string' ? enumValues(source) : undefined
}
}
export function buildApiSpec(source: ApiSpecSource): ApiSpec {
const parameterNames = new Set<string>()
const outputKeys = new Set<string>()
return {
title: source.title,
workflowId: source.workflowId,
submitUrl: `${API_BASE_URL}/workflows/${source.workflowId}/run`,
jobUrl: `${API_BASE_URL}/jobs/{job_id}`,
parameters: source.parameters.map((entry) =>
buildParameter(entry, parameterNames)
),
outputs: source.outputs.map((output) => ({
...output,
key: claimUniqueName(parameterName(output.title), outputKeys)
}))
}
}
const MEDIA_PLACEHOLDERS: Record<MediaKind, string> = {
image: 'https://example.com/input.png',
video: 'https://example.com/input.mp4',
audio: 'https://example.com/input.mp3',
mesh: 'https://example.com/input.glb'
}
export function isMediaParameter(parameter: ApiParameter): boolean {
return parameter.type in MEDIA_PLACEHOLDERS
}
function placeholderValue(parameter: ApiParameter): string | number | boolean {
if (parameter.enumValues?.length) return parameter.enumValues[0]
switch (parameter.type) {
case 'integer':
case 'number':
return parameter.minimum ?? 0
case 'boolean':
return false
case 'image':
case 'video':
case 'audio':
case 'mesh':
return MEDIA_PLACEHOLDERS[parameter.type]
default:
return ''
}
}
export function exampleRequestBody(
spec: ApiSpec,
requiredOnly = false
): Record<string, string | number | boolean> {
const parameters = requiredOnly
? spec.parameters.filter((parameter) => parameter.required)
: spec.parameters
return Object.fromEntries(
parameters.map((parameter) => [
parameter.name,
parameter.defaultValue ?? placeholderValue(parameter)
])
)
}
export function exampleResponseBody(spec: ApiSpec): Record<string, unknown> {
return {
job_id: 'job_01hzxyz',
status: 'completed',
outputs: Object.fromEntries(
spec.outputs.map((output) => [
output.key,
[
{
filename: `${output.key}.png`,
url: `${API_BASE_URL}/assets/${output.key}.png`
}
]
])
)
}
}

View File

@@ -1,45 +0,0 @@
import type { HighlighterCore, ShikiTransformer } from 'shiki/core'
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
export type HighlightLanguage = 'javascript' | 'python' | 'bash' | 'json'
let highlighterPromise: Promise<HighlighterCore> | undefined
function getHighlighter(): Promise<HighlighterCore> {
highlighterPromise ??= createHighlighterCore({
themes: [
import('shiki/themes/github-light.mjs'),
import('shiki/themes/github-dark.mjs')
],
langs: [
import('shiki/langs/javascript.mjs'),
import('shiki/langs/python.mjs'),
import('shiki/langs/bash.mjs'),
import('shiki/langs/json.mjs')
],
engine: createJavaScriptRegexEngine()
})
return highlighterPromise
}
// The surrounding block owns the background; without this, shiki's inline
// pre style would paint over the design-system token.
const stripPreStyle: ShikiTransformer = {
pre(node) {
delete node.properties.style
}
}
export async function highlightCode(
code: string,
language: HighlightLanguage,
lightTheme: boolean
): Promise<string> {
const highlighter = await getHighlighter()
return highlighter.codeToHtml(code, {
lang: language,
theme: lightTheme ? 'github-light' : 'github-dark',
transformers: [stripPreStyle]
})
}

View File

@@ -1,91 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildApiSpec } from './apiSpec'
import { buildLlmSummary } from './llmSummary'
const spec = buildApiSpec({
title: 'Portrait Maker',
workflowId: 'wf_test1',
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a cat' },
{
displayName: 'Image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png'] }
},
{
displayName: 'Steps',
widgetType: 'number',
value: 20,
options: { precision: 0, min: 1, max: 100 }
}
],
outputs: [{ nodeId: '9', title: 'Save Image' }]
})
describe('buildLlmSummary', () => {
const summary = buildLlmSummary(spec)
it('includes usage examples for all three languages', () => {
expect(summary).toContain('### cURL')
expect(summary).toContain('### JavaScript')
expect(summary).toContain('### Python')
expect(summary).toContain('@comfyorg/client')
expect(summary).toContain('comfy_client.subscribe(')
})
it('documents parameters with type, requirement, and constraints', () => {
expect(summary).toContain('**`prompt`** (`string`, _optional_)')
expect(summary).toContain('**`image`** (`image`, _required_)')
expect(summary).toContain('Range: `1` to `100`')
expect(summary).toContain('an HTTPS URL or a base64 data URI')
})
it('shows a required-only example when required parameters exist', () => {
expect(summary).toContain('**Required Parameters Example**')
const requiredExample = summary
.split('**Required Parameters Example**')[1]
.split('```json')[1]
.split('```')[0]
expect(JSON.parse(requiredExample)).toEqual({
image: 'https://example.com/input.png'
})
})
it('omits the required-only example when nothing is required', () => {
const optionalOnly = buildApiSpec({
title: 'T',
workflowId: 'wf_x',
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a' }
],
outputs: []
})
expect(buildLlmSummary(optionalOnly)).not.toContain(
'**Required Parameters Example**'
)
})
it('documents outputs by response key with an example response', () => {
expect(summary).toContain('**`save_image`** (`list<Asset>`): Save Image')
expect(summary).toContain('"job_id"')
})
it('lists optional features, including the upload helper for media inputs', () => {
expect(summary).toContain('## Optional Features')
expect(summary).toContain('`webhook_url`')
expect(summary).toContain('comfy.storage.upload(file)')
const noMedia = buildApiSpec({
title: 'T',
workflowId: 'wf_x',
parameters: [
{ displayName: 'Prompt', widgetType: 'customtext', value: 'a' }
],
outputs: []
})
expect(buildLlmSummary(noMedia)).not.toContain('storage.upload')
})
})

View File

@@ -1,142 +0,0 @@
import type { ApiParameter, ApiSpec } from './apiSpec'
import {
exampleRequestBody,
exampleResponseBody,
isMediaParameter
} from './apiSpec'
import { buildSnippet } from './apiSnippets'
function parameterBlock(parameter: ApiParameter): string {
const requirement = parameter.required ? '_required_' : '_optional_'
const heading = `- **\`${parameter.name}\`** (\`${parameter.type}\`, ${requirement})`
const lines = [
parameter.nodeTitle ? `${heading}: ${parameter.nodeTitle}` : heading
]
if (isMediaParameter(parameter)) {
lines.push(' - Accepts: an HTTPS URL or a base64 data URI')
}
if (parameter.defaultValue !== undefined) {
lines.push(` - Default: \`${JSON.stringify(parameter.defaultValue)}\``)
}
if (parameter.minimum !== undefined && parameter.maximum !== undefined) {
lines.push(
` - Range: \`${parameter.minimum}\` to \`${parameter.maximum}\``
)
} else if (parameter.minimum !== undefined) {
lines.push(` - Min: \`${parameter.minimum}\``)
} else if (parameter.maximum !== undefined) {
lines.push(` - Max: \`${parameter.maximum}\``)
}
if (parameter.enumValues?.length) {
lines.push(
` - Options: ${parameter.enumValues.map((value) => `\`"${value}"\``).join(', ')}`
)
}
return lines.join('\n')
}
function inputSchemaSection(spec: ApiSpec): string {
if (!spec.parameters.length) return '_This API takes no parameters._'
const blocks = spec.parameters.map(parameterBlock).join('\n\n')
const requiredExample = spec.parameters.some((p) => p.required)
? `
**Required Parameters Example**:
\`\`\`json
${JSON.stringify(exampleRequestBody(spec, true), null, 2)}
\`\`\``
: ''
return `The API accepts the following input parameters:
${blocks}${requiredExample}
**Full Example**:
\`\`\`json
${JSON.stringify(exampleRequestBody(spec), null, 2)}
\`\`\``
}
function outputSchemaSection(spec: ApiSpec): string {
const outputLines = spec.outputs
.map(
(output) => `- **\`${output.key}\`** (\`list<Asset>\`): ${output.title}`
)
.join('\n')
return `When the job completes, \`outputs\` contains one key per workflow
output; each is a list of \`{ "filename", "url" }\` assets.
${outputLines}
**Example Response**:
\`\`\`json
${JSON.stringify(exampleResponseBody(spec), null, 2)}
\`\`\``
}
function optionalFeaturesSection(spec: ApiSpec): string {
const lines = [
'- **Webhook** — include `webhook_url` when submitting (`webhookUrl` in the JavaScript client) to receive a POST callback when the job completes, instead of polling.'
]
if (spec.parameters.some(isMediaParameter)) {
lines.push(
'- **Media uploads** — media inputs accept any HTTPS URL or base64 data URI; the clients also provide an upload helper (`comfy.storage.upload(file)` / `comfy_client.upload_file(path)`) that returns a hosted URL.'
)
}
return lines.join('\n')
}
export function buildLlmSummary(spec: ApiSpec): string {
return `# ${spec.title}
> A ComfyUI workflow exposed as an HTTP API: submit a job with the typed
> inputs below and receive generated assets.
## Overview
- **Endpoint**: \`POST ${spec.submitUrl}\`
- **Workflow ID**: \`${spec.workflowId}\`
- **Execution**: async queue — submitting returns a \`job_id\`; poll
\`GET ${spec.jobUrl}\` until \`status\` is \`completed\`.
- **Authentication**: send \`Authorization: Bearer <COMFY_API_KEY>\` on every
request. The client libraries read \`COMFY_API_KEY\` from the environment.
## Input Schema
${inputSchemaSection(spec)}
## Output Schema
${outputSchemaSection(spec)}
## Optional Features
${optionalFeaturesSection(spec)}
## Usage Examples
### JavaScript
\`\`\`javascript
${buildSnippet('javascript', spec)}
\`\`\`
### Python
\`\`\`python
${buildSnippet('python', spec)}
\`\`\`
### cURL
\`\`\`bash
${buildSnippet('curl', spec)}
\`\`\`
`
}

View File

@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildApiSpec } from './apiSpec'
import { buildOpenApiDocument } from './openApiDocument'
interface OpenApiTestShape {
paths: Record<string, unknown>
components: {
schemas: {
Input: { properties: Record<string, unknown>; required?: string[] }
Job: { properties: Record<string, unknown> }
}
}
}
describe('buildOpenApiDocument', () => {
it('describes the submit and poll endpoints with a typed input schema', () => {
const spec = buildApiSpec({
title: 'Portrait Maker',
workflowId: 'wf_test1',
parameters: [
{
displayName: 'Steps',
widgetType: 'number',
value: 20,
options: { precision: 0, min: 1, max: 100 }
},
{
displayName: 'Image',
widgetType: 'combo',
value: 'photo.png',
mediaKind: 'image',
options: { values: ['photo.png'] }
}
],
outputs: [{ nodeId: '9', title: 'Save Image' }]
})
const document = buildOpenApiDocument(spec) as unknown as OpenApiTestShape
expect(Object.keys(document.paths)).toEqual([
'/workflows/wf_test1/run',
'/jobs/{job_id}'
])
expect(document.components.schemas.Input.properties.steps).toMatchObject({
type: 'integer',
default: 20,
minimum: 1,
maximum: 100
})
expect(document.components.schemas.Input.properties.image).toMatchObject({
type: 'string',
format: 'uri'
})
expect(
document.components.schemas.Input.properties.image
).not.toHaveProperty('enum')
expect(document.components.schemas.Input.required).toEqual(['image'])
expect(document.components.schemas.Job.properties.outputs).toMatchObject({
type: 'object',
properties: {
save_image: {
type: 'array',
items: { $ref: '#/components/schemas/OutputAsset' }
}
}
})
})
})

View File

@@ -1,137 +0,0 @@
import type { ApiParameter, ApiSpec } from './apiSpec'
import { API_BASE_URL, isMediaParameter } from './apiSpec'
type JsonRecord = Record<string, unknown>
function parameterSchema(parameter: ApiParameter): JsonRecord {
if (isMediaParameter(parameter)) {
return {
type: 'string',
format: 'uri',
description: `${
parameter.type.charAt(0).toUpperCase() + parameter.type.slice(1)
} input — an HTTPS URL or a base64 data URI`
}
}
const schema: JsonRecord = { type: parameter.type }
if (parameter.defaultValue !== undefined)
schema.default = parameter.defaultValue
if (parameter.minimum !== undefined) schema.minimum = parameter.minimum
if (parameter.maximum !== undefined) schema.maximum = parameter.maximum
if (parameter.enumValues) schema.enum = parameter.enumValues
if (parameter.nodeTitle) schema.description = parameter.nodeTitle
return schema
}
const outputAssetSchema: JsonRecord = {
type: 'object',
properties: {
filename: { type: 'string' },
url: { type: 'string', format: 'uri' }
}
}
function jobSchema(spec: ApiSpec): JsonRecord {
return {
type: 'object',
properties: {
job_id: { type: 'string' },
status: {
type: 'string',
enum: ['queued', 'running', 'completed', 'failed']
},
outputs: {
type: 'object',
properties: Object.fromEntries(
spec.outputs.map((output) => [
output.key,
{
type: 'array',
items: { $ref: '#/components/schemas/OutputAsset' },
description: output.title
}
])
)
}
}
}
}
export function buildOpenApiDocument(spec: ApiSpec): JsonRecord {
const required = spec.parameters
.filter((parameter) => parameter.required)
.map((parameter) => parameter.name)
const inputSchema: JsonRecord = {
type: 'object',
properties: Object.fromEntries(
spec.parameters.map((parameter) => [
parameter.name,
parameterSchema(parameter)
])
),
...(required.length ? { required } : {})
}
return {
openapi: '3.1.0',
info: { title: spec.title, version: '1.0.0' },
servers: [{ url: API_BASE_URL }],
paths: {
[`/workflows/${spec.workflowId}/run`]: {
post: {
operationId: 'submitJob',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Input' }
}
}
},
responses: {
'202': {
description: 'Job accepted',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Job' }
}
}
}
}
}
},
'/jobs/{job_id}': {
get: {
operationId: 'getJob',
parameters: [
{
name: 'job_id',
in: 'path',
required: true,
schema: { type: 'string' }
}
],
responses: {
'200': {
description: 'Job status and outputs',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Job' }
}
}
}
}
}
}
},
components: {
schemas: {
Input: inputSchema,
Job: jobSchema(spec),
OutputAsset: outputAssetSchema
}
}
}
}

View File

@@ -1,73 +0,0 @@
import type { ComputedRef } from 'vue'
import { computed } from 'vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { ApiSpec, MediaKind, ParameterSource } from './apiSpec'
import { buildApiSpec, deriveWorkflowId } from './apiSpec'
export function useApiSpec(): ComputedRef<ApiSpec> {
const workflowStore = useWorkflowStore()
const appModeStore = useAppModeStore()
const nodeDefStore = useNodeDefStore()
const resolvedInputs = useResolvedSelectedInputs()
function mediaKindForWidget(
node: LGraphNode,
widgetName: string
): MediaKind | undefined {
const spec = nodeDefStore.getInputSpecForWidget(node, widgetName)
if (!spec) return undefined
if (
('image_upload' in spec && spec.image_upload) ||
('animated_image_upload' in spec && spec.animated_image_upload)
) {
return 'image'
}
if ('video_upload' in spec && spec.video_upload) return 'video'
if ('audio_upload' in spec && spec.audio_upload) return 'audio'
if ('mesh_upload' in spec && spec.mesh_upload) return 'mesh'
return undefined
}
return computed(() => {
const parameters = resolvedInputs.value.flatMap(
(entry): ParameterSource[] => {
if (entry.status !== 'resolved') return []
const { widget, node, displayName } = entry
return [
{
displayName: widget.label ?? displayName,
nodeTitle: node.title,
widgetType: widget.type,
value: widget.value,
mediaKind: mediaKindForWidget(node, widget.name),
options: {
min: widget.options?.min,
max: widget.options?.max,
precision: widget.options?.precision,
values: widget.options?.values
}
}
]
}
)
const outputs = appModeStore.selectedOutputs.map((nodeId) => ({
nodeId: String(nodeId),
title: app.rootGraph?.getNodeById(nodeId)?.title ?? String(nodeId)
}))
const workflow = workflowStore.activeWorkflow
return buildApiSpec({
title: workflow?.filename ?? 'workflow',
workflowId: deriveWorkflowId(workflow?.path ?? 'workflow'),
parameters,
outputs
})
})
}

View File

@@ -21,7 +21,7 @@ import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder, setMode } = useAppMode()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
@@ -113,21 +113,6 @@ function showApps() {
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('builderToolbar.viewApi'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('builderToolbar.viewApi')"
class="size-10 rounded-lg"
@click="setMode('api')"
>
<i class="icon-[lucide--braces] size-4" />
</Button>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
</div>

View File

@@ -27,13 +27,11 @@ vi.mock('@/composables/useAppMode', () => ({
}))
const mockHasOutputs = ref(true)
const mockBuilderTarget = ref<'app' | 'api'>('app')
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
exitBuilder: mockExitBuilder,
hasOutputs: mockHasOutputs,
builderTarget: mockBuilderTarget,
$id: 'appMode'
})
}))
@@ -83,13 +81,9 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
builderMenu: {
exitAppBuilder: 'Exit app builder',
exitApiBuilder: 'Exit API builder'
},
builderMenu: { exitAppBuilder: 'Exit app builder' },
builderToolbar: {
viewApp: 'View app',
viewApi: 'View API',
saveAs: 'Save as',
app: 'App',
nodeGraph: 'Node graph'
@@ -109,7 +103,6 @@ describe('BuilderFooterToolbar', () => {
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockBuilderTarget.value = 'app'
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
})
@@ -181,20 +174,6 @@ describe('BuilderFooterToolbar', () => {
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('calls setMode api on view API click for the api target', async () => {
mockBuilderTarget.value = 'api'
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /view api/i }))
expect(mockSetMode).toHaveBeenCalledWith('api')
})
it('disables next on the outputs step for the api target', () => {
mockBuilderTarget.value = 'api'
mockState.mode = 'builder:outputs'
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('shows "Save as" when workflow is temporary', () => {
mockActiveWorkflow.value = { isTemporary: true }
renderComponent()

View File

@@ -4,7 +4,7 @@
>
<!-- "Opens as" attachment tab -->
<BuilderOpensAsPopover
v-if="isSaved && !isApiTarget"
v-if="isSaved"
:is-app-mode="isAppMode"
@select="onSetDefaultView"
/>
@@ -15,18 +15,10 @@
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{
isApiTarget
? t('builderMenu.exitApiBuilder')
: t('builderMenu.exitAppBuilder')
}}
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button variant="secondary" size="lg" @click="onViewResult">
{{
isApiTarget
? t('builderToolbar.viewApi')
: t('builderToolbar.viewApp')
}}
<Button variant="secondary" size="lg" @click="onViewApp">
{{ t('builderToolbar.viewApp') }}
</Button>
<Button
variant="textonly"
@@ -160,8 +152,7 @@ const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const workflowStore = useWorkflowStore()
const { isBuilderMode, setMode } = useAppMode()
const { hasOutputs, builderTarget } = storeToRefs(appModeStore)
const isApiTarget = computed(() => builderTarget.value === 'api')
const { hasOutputs } = storeToRefs(appModeStore)
const {
isFirstStep,
isLastStep,
@@ -210,8 +201,8 @@ function onExitBuilder() {
appModeStore.exitBuilder()
}
function onViewResult() {
setMode(isApiTarget.value ? 'api' : 'app')
function onViewApp() {
setMode('app')
}
function onSetDefaultView(openAsApp: boolean) {

View File

@@ -9,11 +9,11 @@
'data-[state=open]:bg-secondary-background-hover'
)
"
:aria-label="builderLabel"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
>
<i class="icon-[lucide--hammer] size-4" />
<span class="text-sm font-medium">
{{ builderLabel }}
{{ t('linearMode.appModeToolbar.appBuilder') }}
</span>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</button>
@@ -52,19 +52,12 @@ import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs, builderTarget } = storeToRefs(appModeStore)
const { hasOutputs } = storeToRefs(appModeStore)
const { setMode } = useAppMode()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const isApiTarget = computed(() => builderTarget.value === 'api')
const builderLabel = computed(() =>
isApiTarget.value
? t('linearMode.appModeToolbar.apiBuilder')
: t('linearMode.appModeToolbar.appBuilder')
)
const menuItems = computed(() => [
{
label: t('g.save'),
@@ -72,21 +65,13 @@ const menuItems = computed(() => [
disabled: !hasOutputs.value,
action: onSave
},
isApiTarget.value
? {
label: t('builderToolbar.viewApi'),
icon: 'icon-[lucide--braces]',
action: onEnterApiMode
}
: {
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: isApiTarget.value
? t('builderMenu.exitApiBuilder')
: t('builderMenu.exitAppBuilder'),
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--x]',
action: onExitBuilder
}
@@ -108,11 +93,6 @@ function onEnterAppMode(close: () => void) {
close()
}
function onEnterApiMode(close: () => void) {
setMode('api')
close()
}
function onExitBuilder(close: () => void) {
appModeStore.exitBuilder()
close()

View File

@@ -20,7 +20,7 @@
/>
</div>
<div v-if="showDefaultViewChoice" class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
@@ -54,14 +54,9 @@ import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
const {
defaultFilename,
defaultOpenAsApp = true,
showDefaultViewChoice = true
} = defineProps<{
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
defaultFilename: string
defaultOpenAsApp?: boolean
showDefaultViewChoice?: boolean
}>()
const emit = defineEmits<{

View File

@@ -34,7 +34,6 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -46,34 +45,31 @@ import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const { activeStep, navigateToStep, steps: targetSteps } = useBuilderSteps()
const { 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'
const stepDefinitions: Record<
BuilderStepId,
BuilderToolbarStep<BuilderStepId>
> = {
'builder:inputs': {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
},
'builder:outputs': {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
},
'builder:arrange': {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const steps = computed(() => targetSteps.value.map((id) => stepDefinitions[id]))
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -55,17 +55,8 @@ vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
}))
const mockBuilderTarget = vi.hoisted(() => ({
value: 'app' as 'app' | 'api'
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
exitBuilder: mockExitBuilder,
get builderTarget() {
return mockBuilderTarget.value
}
})
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
}))
vi.mock('@/stores/dialogStore', () => ({
@@ -94,7 +85,6 @@ describe('useBuilderSave', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveWorkflow.value = null
mockBuilderTarget.value = 'app'
})
describe('save()', () => {
@@ -186,28 +176,6 @@ describe('useBuilderSave', () => {
const { props } = mockShowLayoutDialog.mock.calls[0][0]
expect(props.defaultOpenAsApp).toBe(false)
})
it('shows the default view choice for the app target', () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { saveAs } = useBuilderSave()
saveAs()
const { props } = mockShowLayoutDialog.mock.calls[0][0]
expect(props.showDefaultViewChoice).toBe(true)
})
it('hides the default view choice and preserves the current view for the api target', () => {
mockBuilderTarget.value = 'api'
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: null }
const { saveAs } = useBuilderSave()
saveAs()
const { props } = mockShowLayoutDialog.mock.calls[0][0]
expect(props.showDefaultViewChoice).toBe(false)
expect(props.defaultOpenAsApp).toBe(false)
})
})
describe('save dialog callbacks', () => {
@@ -314,22 +282,6 @@ describe('useBuilderSave', () => {
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
})
it('skips default view telemetry and shows the api success dialog for the api target', async () => {
mockBuilderTarget.value = 'api'
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockTrackDefaultViewSet).not.toHaveBeenCalled()
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyApi')
expect(successCall.footerProps.confirmText).toBe('builderToolbar.viewApi')
successCall.footerProps.onConfirm()
expect(mockSetMode).toHaveBeenCalledWith('api')
})
it('prevents concurrent handleSaveAs calls', async () => {
let resolveSaveAs!: (v: boolean) => void
mockSaveWorkflowAs.mockReturnValueOnce(

View File

@@ -50,18 +50,12 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
const isApiTarget = appModeStore.builderTarget === 'api'
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename: workflow.filename,
// The API builder hides the choice and must not change how the
// workflow opens, so it preserves the current default view.
defaultOpenAsApp: isApiTarget
? workflow.initialMode === 'app'
: workflow.initialMode !== 'graph',
showDefaultViewChoice: !isApiTarget,
defaultOpenAsApp: workflow.initialMode !== 'graph',
onSave: handleSaveAs,
onClose: () => closeDialog(SAVE_DIALOG_KEY)
}
@@ -81,14 +75,10 @@ export function useBuilderSave() {
})
if (!saved) return
closeDialog(SAVE_DIALOG_KEY)
if (appModeStore.builderTarget === 'api') {
showApiSuccessDialog()
return
}
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog(SAVE_DIALOG_KEY)
showSuccessDialog(openAsApp ? 'app' : 'graph')
} catch (e) {
toastErrorHandler(e)
@@ -98,30 +88,6 @@ export function useBuilderSave() {
}
}
function showApiSuccessDialog() {
showConfirmDialog({
key: SUCCESS_DIALOG_KEY,
headerProps: {
title: t('builderSave.successTitle'),
icon: 'icon-[lucide--circle-check-big] text-green-500'
},
props: {
promptText: t('builderSave.successBodyApi'),
preserveNewlines: true
},
footerProps: {
cancelText: t('g.close'),
confirmText: t('builderToolbar.viewApi'),
confirmVariant: 'secondary' as const,
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
setMode('api')
}
}
})
}
function showSuccessDialog(viewType: 'app' | 'graph') {
const promptText =
viewType === 'app'

View File

@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import { builderStepsForTarget } from './useBuilderSteps'
describe('builderStepsForTarget', () => {
it('includes the arrange step for the app target', () => {
expect(builderStepsForTarget('app')).toEqual([
'builder:inputs',
'builder:outputs',
'builder:arrange'
])
})
it('ends at output selection for the api target', () => {
expect(builderStepsForTarget('api')).toEqual([
'builder:inputs',
'builder:outputs'
])
})
})

View File

@@ -1,33 +1,21 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { BuilderTarget } from '@/utils/appMode'
const APP_BUILDER_STEPS = [
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:arrange'
] as const
const API_BUILDER_STEPS = ['builder:inputs', 'builder:outputs'] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
export type BuilderStepId = (typeof APP_BUILDER_STEPS)[number]
export function builderStepsForTarget(
target: BuilderTarget
): readonly BuilderStepId[] {
return target === 'api' ? API_BUILDER_STEPS : APP_BUILDER_STEPS
}
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { builderTarget } = storeToRefs(useAppModeStore())
const steps = computed(() => builderStepsForTarget(builderTarget.value))
const activeStep = computed<BuilderStepId>(() => {
if (isBuilderMode.value) {
@@ -36,16 +24,16 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
return 'builder:inputs'
})
const activeStepIndex = computed(() => steps.value.indexOf(activeStep.value))
const activeStepIndex = computed(() =>
BUILDER_STEPS.indexOf(activeStep.value)
)
const isFirstStep = computed(() => activeStepIndex.value === 0)
const isLastStep = computed(() => {
if (builderTarget.value === 'app' && !options?.hasOutputs?.value)
return (
activeStepIndex.value >= APP_BUILDER_STEPS.indexOf('builder:arrange')
)
return activeStepIndex.value >= steps.value.length - 1
if (!options?.hasOutputs?.value)
return activeStepIndex.value >= ARRANGE_INDEX
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
@@ -56,16 +44,15 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
function goBack() {
if (isFirstStep.value) return
setMode(steps.value[activeStepIndex.value - 1])
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
setMode(steps.value[activeStepIndex.value + 1])
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
steps,
activeStep,
activeStepIndex,
isFirstStep,

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="viewErrorsInGraph"
@click="seeErrors"
>
{{
appMode
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -20,7 +20,6 @@ export function useAppMode() {
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(() => isAppModeValue(mode.value))
const isApiMode = computed(() => mode.value === 'api')
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)
@@ -39,7 +38,6 @@ export function useAppMode() {
isSelectOutputsMode,
isArrangeMode,
isAppMode,
isApiMode,
isGraphMode,
setMode
}

View File

@@ -1,105 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -1,22 +0,0 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -249,18 +249,6 @@ export function useWorkflowActionsMenu(
isNew: true
})
addItem({
id: 'enter-api-builder-mode',
label: t('breadcrumbsMenu.enterApiBuilderMode'),
icon: 'icon-[lucide--braces]',
command: async () => {
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder('api')
},
visible: showAppModeItems,
isNew: true
})
addItem({
id: 'clear-workflow',
label: t('breadcrumbsMenu.clearWorkflow'),

View File

@@ -3068,7 +3068,6 @@
"exitAppMode": "Exit app mode",
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"enterApiBuilderMode": "Build API",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
@@ -3682,68 +3681,6 @@
"dismiss": "Dismiss"
}
},
"apiBuilder": {
"pageSubtitle": "Call this workflow as an HTTP API. Submit a job and get back generated assets.",
"copySchema": "Schema",
"llmsLabel": "LLMs",
"llmsMenu": "LLMs options",
"openMarkdown": "Open Markdown",
"copyMarkdown": "Copy Markdown",
"editApi": "Edit API",
"apiDocs": "API Docs",
"logs": "Logs",
"endpointsTitle": "Endpoints",
"endpointsDescription": "Submitting returns a job_id — poll it until the job completes, or include a webhook_url to be called back. Authenticate every request with your API key.",
"submitEndpoint": "Submit a job",
"pollEndpoint": "Get status & outputs",
"parametersTitle": "Input parameters",
"parametersDescription": "Each parameter is a field in the JSON request body.",
"parametersEmpty": "No inputs yet. Edit the API and select the widgets to expose as parameters.",
"requiredLabel": "Required",
"acceptsLabel": "Accepts",
"mediaAccepts": "HTTPS URL or base64 data URI",
"defaultLabel": "Default",
"minLabel": "Min",
"maxLabel": "Max",
"optionsLabel": "Options",
"optionsOverflow": "{preview}, +{count} more",
"responseTitle": "Response",
"responseDescription": "When the job completes, each key in outputs holds a list of downloadable assets.",
"outputsLabel": "Outputs",
"exampleResponse": "Example response",
"snippetsTitle": "Client code",
"testTitle": "Test this API",
"testDescription": "Set the inputs and run to preview what the API returns.",
"testNoInputs": "No inputs yet. Edit the API to add parameters.",
"latestResult": "Latest result",
"noResults": "No runs yet. Results will appear here."
},
"apiLogs": {
"title": "API logs",
"subtitle": "Recent jobs across your workflow APIs, including errors.",
"viewMore": "View more",
"columns": {
"id": "ID",
"workflow": "Workflow",
"source": "Source",
"status": "Status",
"queued": "Queued",
"running": "Running",
"duration": "Total duration",
"cost": "Approximate cost",
"created": "Created"
},
"statuses": {
"succeeded": "Succeeded",
"failed": "Failed",
"running": "Running",
"queued": "Queued"
},
"sources": {
"api": "API",
"playground": "Playground"
}
},
"vueNodesMigration": {
"message": "Prefer the legacy design?",
"button": "Switch back"
@@ -3785,7 +3722,6 @@
},
"appModeToolbar": {
"appBuilder": "App builder",
"apiBuilder": "API builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
@@ -4512,7 +4448,6 @@
"nodeGraph": "Node graph",
"nodeGraphDescription": "Opens as node graph by default",
"viewApp": "View app",
"viewApi": "View API",
"saveAs": "Save as",
"filename": "Filename",
"emptyWorkflowTitle": "This workflow has no nodes",
@@ -4526,13 +4461,11 @@
"successTitle": "Successfully saved",
"successBody": "Would you like to view it now?",
"successBodyApp": "This workflow will open in App Mode by default from now on.\n\nWould you like to view it now?",
"successBodyGraph": "This workflow will open as a node graph.",
"successBodyApi": "Your API details are ready.\n\nWould you like to view them now?"
"successBodyGraph": "This workflow will open as a node graph."
},
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder",
"exitApiBuilder": "Exit API builder"
"exitAppBuilder": "Exit app builder"
},
"cloudNotification": {
"title": "Run ComfyUI in the Cloud",

View File

@@ -672,7 +672,6 @@ export type ExecutionTriggerSource =
| 'legacy_ui'
| 'unknown'
| 'linear'
| 'api_builder'
/**
* Union type for all possible telemetry event properties

View File

@@ -1,208 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -148,10 +134,9 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -181,24 +166,18 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -219,14 +198,9 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -1,2 +0,0 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -80,11 +80,6 @@ const router = createRouter({
path: 'user-select',
name: 'UserSelectView',
component: () => import('@/views/UserSelectView.vue')
},
{
path: 'api-logs',
name: 'ApiLogsView',
component: () => import('@/views/ApiLogsView.vue')
}
]
}

View File

@@ -26,7 +26,6 @@ import {
} from '@/utils/litegraphUtil'
import { parseNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { BuilderTarget } from '@/utils/appMode'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
@@ -55,7 +54,6 @@ export const useAppModeStore = defineStore('appMode', () => {
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const showVueNodeSwitchPopup = ref(false)
const builderTarget = ref<BuilderTarget>('app')
const selectedInputs = ref<LinearInput[]>([])
const selectedOutputs = ref<NodeId[]>([])
@@ -237,12 +235,10 @@ export const useAppModeStore = defineStore('appMode', () => {
autoEnableVueNodes(inSelect)
})
function enterBuilder(target: BuilderTarget = 'app') {
builderTarget.value = target
function enterBuilder() {
if (!hasNodes.value) {
emptyWorkflowDialog.show({
onEnterBuilder: () => enterBuilder(target),
onEnterBuilder: () => enterBuilder(),
onDismiss: () => setMode('graph')
})
return
@@ -253,7 +249,7 @@ export const useAppModeStore = defineStore('appMode', () => {
useSidebarTabStore().activeSidebarTabId = null
setMode(
target === 'app' && mode.value === 'app' && hasOutputs.value
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'
: 'builder:inputs'
)
@@ -285,7 +281,6 @@ export const useAppModeStore = defineStore('appMode', () => {
}
return {
builderTarget,
enterBuilder,
exitBuilder,
hasNodes,

View File

@@ -285,6 +285,26 @@ describe('useSubgraphStore', () => {
consoleSpy.mockRestore()
})
it('should reject blueprints without a root node', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch({
'empty-node.json': {
...mockGraph,
nodes: []
}
})
const error = consoleSpy.mock.calls.find(
([message]) => message === 'Failed to load subgraph blueprint'
)?.[1]
expect(error).toBeInstanceOf(TypeError)
expect((error as Error).message).toBe(
"Subgraph blueprint 'empty-node' must contain a root node"
)
expect(store.subgraphBlueprints).toHaveLength(0)
consoleSpy.mockRestore()
})
it('should handle global blueprint with rejected data promise gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await mockFetch(

View File

@@ -39,6 +39,10 @@ async function confirmOverwrite(name: string): Promise<boolean | null> {
})
}
type ValidSubgraphWorkflowJSON = ComfyWorkflowJSON & {
definitions: NonNullable<ComfyWorkflowJSON['definitions']>
}
export const useSubgraphStore = defineStore('subgraph', () => {
class SubgraphBlueprint extends ComfyWorkflow {
static override readonly basePath = 'subgraphs/'
@@ -54,18 +58,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = !confirmFirstSave
}
validateSubgraph() {
if (!this.activeState?.definitions)
validateSubgraph(): ValidSubgraphWorkflowJSON {
const activeState = this.activeState
if (!activeState?.definitions)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
const { subgraphs } = this.activeState.definitions
const { nodes } = this.activeState
const validState = activeState as ValidSubgraphWorkflowJSON
const { subgraphs } = validState.definitions
const { nodes } = validState
//Instanceof doesn't function as nodes are serialized
function isSubgraphNode(node: ComfyNode) {
return node && subgraphs.some((s) => s.id === node.type)
}
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return validState
const errors: Record<SerializedNodeId, NodeError> = {}
//mark errors for all but first subgraph node
let firstSubgraphFound = false
@@ -88,7 +94,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
override async save(): Promise<UserFile> {
this.validateSubgraph()
const activeState = this.validateSubgraph()
if (
!this.hasPromptedSave &&
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
@@ -97,7 +103,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = true
}
// Extract metadata from subgraph.extra to workflow.extra before saving
this.extractMetadataToWorkflowExtra()
this.extractMetadataToWorkflowExtra(activeState)
const ret = await super.save()
// Force reload to update initialState with saved metadata
registerNodeDef(await this.load({ force: true }), {
@@ -110,13 +116,14 @@ export const useSubgraphStore = defineStore('subgraph', () => {
* Moves all properties (except workflowRendererVersion) from subgraph.extra
* to workflow.extra, then removes from subgraph.extra to avoid duplication.
*/
private extractMetadataToWorkflowExtra(): void {
if (!this.activeState) return
const subgraph = this.activeState.definitions?.subgraphs?.[0]
private extractMetadataToWorkflowExtra(
activeState: ValidSubgraphWorkflowJSON
): void {
const subgraph = activeState.definitions.subgraphs?.[0]
if (!subgraph?.extra) return
const sgExtra = subgraph.extra as Record<string, unknown>
const workflowExtra = (this.activeState.extra ??= {}) as Record<
const workflowExtra = (activeState.extra ??= {}) as Record<
string,
unknown
>
@@ -129,10 +136,10 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
override async saveAs(path: string) {
this.validateSubgraph()
const activeState = this.validateSubgraph()
this.hasPromptedSave = true
// Extract metadata from subgraph.extra to workflow.extra before saving
this.extractMetadataToWorkflowExtra()
this.extractMetadataToWorkflowExtra(activeState)
const ret = await super.saveAs(path)
// Force reload to update initialState with saved metadata
registerNodeDef(await this.load({ force: true }), {
@@ -146,14 +153,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
if (!force && this.isLoaded) return await super.load({ force })
const loaded = await super.load({ force })
const st = loaded.activeState
const rootNode = st.nodes[0]
if (!rootNode) {
throw new TypeError(
`Subgraph blueprint '${this.filename}' must contain a root node`
)
}
const sg = (st.definitions?.subgraphs ?? []).find(
(sg) => sg.id == st.nodes[0].type
(sg) => sg.id == rootNode.type
)
if (!sg)
throw new Error(
'Loaded subgraph blueprint does not contain valid subgraph'
)
sg.name = st.nodes[0].title = this.filename
sg.name = rootNode.title = this.filename
// Copy blueprint metadata from workflow extra to subgraph extra
// so it's available when editing via canvas.subgraph.extra
@@ -277,7 +290,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
name: string = workflow.filename
) {
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
if (!subgraphNode) {
throw new TypeError(
`Subgraph blueprint '${name}' must contain a root node`
)
}
subgraphNode.inputs ??= []
subgraphNode.outputs ??= []
//NOTE: Types are cast to string. This is only used for input coloring on previews

View File

@@ -1,13 +1,10 @@
export type AppMode =
| 'graph'
| 'app'
| 'api'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
export type BuilderTarget = 'app' | 'api'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined

View File

@@ -1,232 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
type LogStatus = 'succeeded' | 'failed' | 'running' | 'queued'
interface ApiLogEntry {
id: string
workflow: string
source: 'api' | 'playground'
status: LogStatus
queued?: string
running?: string
duration?: string
cost?: string
created: string
error?: string
}
// Design-mock data standing in for the platform jobs backend.
const LOG_ENTRIES: ApiLogEntry[] = [
{
id: 'job_01j9x4r2mk',
workflow: 'api-test',
source: 'api',
status: 'queued',
created: 'Jul 3, 09:14'
},
{
id: 'job_01j9x2m8kq',
workflow: 'api-test',
source: 'api',
status: 'running',
queued: '42ms',
running: '12.1s',
created: 'Jul 3, 09:12'
},
{
id: 'job_01j9x1t3vp',
workflow: 'api-test',
source: 'playground',
status: 'succeeded',
queued: '31ms',
running: '8.0s',
duration: '8.0s',
cost: '$0.04',
created: 'Jul 3, 08:58'
},
{
id: 'job_01j9wzr7dn',
workflow: 'portrait-maker',
source: 'api',
status: 'failed',
queued: '2.3s',
running: '35.6s',
duration: '37.9s',
created: 'Jul 2, 04:35',
error: 'Missing model: sdxl_vae.safetensors'
},
{
id: 'job_01j9wyn1sc',
workflow: 'portrait-maker',
source: 'api',
status: 'succeeded',
queued: '45ms',
running: '1m 50.9s',
duration: '1m 50.9s',
cost: '$0.21',
created: 'Jul 2, 04:32'
},
{
id: 'job_01j9wx8f4t',
workflow: 'video-upscaler',
source: 'api',
status: 'failed',
duration: '32s',
created: 'Jul 1, 13:51',
error: 'Input validation: "image" must be an HTTPS URL or data URI'
},
{
id: 'job_01j9wvq9hz',
workflow: 'video-upscaler',
source: 'api',
status: 'succeeded',
queued: '55ms',
running: '2m 1.1s',
duration: '2m 1.2s',
cost: '$0.32',
created: 'Jul 1, 13:46'
},
{
id: 'job_01j9wtd2xw',
workflow: 'api-test',
source: 'playground',
status: 'succeeded',
queued: '28ms',
running: '6.9s',
duration: '7s',
cost: '$0.04',
created: 'Jul 1, 13:40'
}
]
const STATUS_CLASSES: Record<LogStatus, string> = {
succeeded: 'bg-success-background/20 text-success-background',
failed: 'bg-destructive-background/20 text-destructive-background',
running: 'bg-primary-background/20 text-primary-background',
queued: 'bg-secondary-background text-muted-foreground'
}
const { t } = useI18n()
const router = useRouter()
const columns = computed(() => [
t('apiLogs.columns.id'),
t('apiLogs.columns.workflow'),
t('apiLogs.columns.source'),
t('apiLogs.columns.status'),
t('apiLogs.columns.queued'),
t('apiLogs.columns.running'),
t('apiLogs.columns.duration'),
t('apiLogs.columns.cost'),
t('apiLogs.columns.created')
])
</script>
<template>
<div class="bg-comfy-menu-secondary-bg size-full overflow-y-auto">
<div class="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<header class="flex items-center gap-3">
<Button
variant="secondary"
size="icon-lg"
:aria-label="t('g.back')"
@click="router.back()"
>
<i class="icon-[lucide--arrow-left]" />
</Button>
<div>
<h1 class="m-0 text-2xl font-bold text-base-foreground">
{{ t('apiLogs.title') }}
</h1>
<p class="mt-1 mb-0 text-sm text-muted-foreground">
{{ t('apiLogs.subtitle') }}
</p>
</div>
</header>
<section
class="overflow-x-auto rounded-2xl border border-border-subtle bg-base-background"
>
<table class="w-full border-collapse text-sm">
<thead>
<tr
class="border-b border-border-subtle text-left text-xs text-muted-foreground"
>
<th class="w-8 p-3" />
<th
v-for="column in columns"
:key="column"
class="p-3 font-medium whitespace-nowrap"
>
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in LOG_ENTRIES"
:key="entry.id"
class="border-b border-border-subtle last:border-b-0"
>
<td class="p-3">
<i
v-if="entry.error"
class="icon-[lucide--triangle-alert] size-4 text-destructive-background"
:title="entry.error"
/>
</td>
<td class="p-3 font-mono text-xs whitespace-nowrap">
{{ entry.id }}
</td>
<td class="p-3 font-mono text-xs whitespace-nowrap">
{{ entry.workflow }}
</td>
<td class="p-3 whitespace-nowrap">
{{ t(`apiLogs.sources.${entry.source}`) }}
</td>
<td class="p-3 whitespace-nowrap">
<span
:class="
cn(
'rounded-md px-1.5 py-0.5 text-xs',
STATUS_CLASSES[entry.status]
)
"
>
{{ t(`apiLogs.statuses.${entry.status}`) }}
</span>
</td>
<td class="p-3 whitespace-nowrap tabular-nums">
{{ entry.queued ?? '—' }}
</td>
<td class="p-3 whitespace-nowrap tabular-nums">
{{ entry.running ?? '—' }}
</td>
<td class="p-3 whitespace-nowrap tabular-nums">
{{ entry.duration ?? '—' }}
</td>
<td class="p-3 whitespace-nowrap tabular-nums">
{{ entry.cost ?? '—' }}
</td>
<td
class="p-3 whitespace-nowrap text-muted-foreground tabular-nums"
>
{{ entry.created }}
</td>
</tr>
</tbody>
</table>
</section>
<div>
<Button variant="secondary" size="md">
{{ t('apiLogs.viewMore') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import ApiDocsPage from '@/components/apiBuilder/ApiDocsPage.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
</script>
<template>
<div class="absolute size-full">
<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="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full overflow-y-auto"
>
<ApiDocsPage />
</div>
</div>
</template>

View File

@@ -5,7 +5,7 @@
<div id="comfyui-body-left" class="comfyui-body-left" />
<div id="comfyui-body-right" class="comfyui-body-right" />
<div
v-show="!linearMode && !isApiMode"
v-show="!linearMode"
id="graph-canvas-container"
ref="graphCanvasContainerRef"
class="graph-canvas-container"
@@ -13,7 +13,6 @@
<GraphCanvas @ready="onGraphReady" />
</div>
<LinearView v-if="linearMode" />
<ApiView v-else-if="isApiMode" />
<template v-if="isBuilderMode">
<BuilderToolbar />
<BuilderMenu />
@@ -98,7 +97,6 @@ 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 ApiView from '@/views/ApiView.vue'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
@@ -113,7 +111,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const { isBuilderMode, mode, isAppMode, isApiMode } = useAppMode()
const { isBuilderMode, mode, isAppMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
watch(linearMode, (isLinear) => {

View File

@@ -9,6 +9,7 @@ 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'
@@ -164,6 +165,7 @@ function dragDrop(e: DragEvent) {
</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"