mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 23:57:53 +00:00
Compare commits
1 Commits
API-builde
...
shihchi/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef478f1cdc |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
95
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -672,7 +672,6 @@ export type ExecutionTriggerSource =
|
||||
| 'legacy_ui'
|
||||
| 'unknown'
|
||||
| 'linear'
|
||||
| 'api_builder'
|
||||
|
||||
/**
|
||||
* Union type for all possible telemetry event properties
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -1,2 +0,0 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user