mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
Compare commits
8 Commits
jaewon/fe-
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283d23e21b | ||
|
|
2bac957e09 | ||
|
|
08ee925811 | ||
|
|
fb5b4a62ba | ||
|
|
cb62604d21 | ||
|
|
d02c5d374f | ||
|
|
c0ef283a05 | ||
|
|
d405002127 |
4
apps/website/public/favicon-dark.svg
Normal file
4
apps/website/public/favicon-dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#F0EFED"/>
|
||||
<path d="M31.0126 30.4797C31.0576 30.3275 31.0822 30.1671 31.0822 29.9985C31.0822 29.0649 30.3294 28.3081 29.4006 28.3081H21.8643C21.4593 28.3122 21.1279 27.9832 21.1279 27.576C21.1279 27.5019 21.1401 27.432 21.1565 27.3662L23.1858 20.259C23.2717 19.9465 23.5581 19.7161 23.8936 19.7161L31.4586 19.7079C33.0542 19.7079 34.4003 18.6262 34.8053 17.1497L35.9427 13.1889C35.9795 13.0491 36 12.8969 36 12.7447C36 11.8152 35.2513 11.0625 34.3266 11.0625H25.1742C23.5868 11.0625 22.2448 12.136 21.8316 13.5961L21.0624 16.2983C20.9724 16.6068 20.6901 16.833 20.3546 16.833H18.1575C16.5823 16.833 15.2526 17.8859 14.8271 19.3295L12.0614 29.0402C12.0205 29.1841 12 29.3404 12 29.4967C12 30.4304 12.7528 31.1871 13.6816 31.1871H15.8418C16.2468 31.1871 16.5782 31.5162 16.5782 31.9275C16.5782 31.9974 16.5701 32.0673 16.5496 32.1331L15.7845 34.8107C15.7477 34.9546 15.7232 35.1027 15.7232 35.2549C15.7232 36.1844 16.4719 36.937 17.3965 36.937L26.553 36.9288C28.1446 36.9288 29.4865 35.8512 29.8957 34.3829L31.0085 30.4838L31.0126 30.4797Z" fill="#211927"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
apps/website/public/favicon-light.svg
Normal file
11
apps/website/public/favicon-light.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3062_2148)">
|
||||
<path d="M36.8451 0H11.1549C4.99423 0 0 4.99423 0 11.1549V36.8451C0 43.0058 4.99423 48 11.1549 48H36.8451C43.0058 48 48 43.0058 48 36.8451V11.1549C48 4.99423 43.0058 0 36.8451 0Z" fill="#211927"/>
|
||||
<path d="M31.0126 30.48C31.0576 30.3278 31.0822 30.1674 31.0822 29.9987C31.0822 29.0651 30.3294 28.3083 29.4006 28.3083H21.8643C21.4592 28.3124 21.1278 27.9834 21.1278 27.5762C21.1278 27.5022 21.1401 27.4323 21.1565 27.3665L23.1858 20.2593C23.2718 19.9467 23.5581 19.7164 23.8936 19.7164L31.4586 19.7082C33.0542 19.7082 34.4001 18.6264 34.8054 17.1499L35.9429 13.1891C35.9794 13.0493 36 12.8971 36 12.7449C36 11.8154 35.2513 11.0627 34.3268 11.0627H25.1742C23.5868 11.0627 22.2448 12.1362 21.8316 13.5963L21.0624 16.2985C20.9724 16.607 20.6901 16.8332 20.3546 16.8332H18.1575C16.5823 16.8332 15.2526 17.8861 14.8271 19.3298L12.0614 29.0404C12.0205 29.1844 12 29.3407 12 29.4969C12 30.4306 12.7528 31.1874 13.6816 31.1874H15.8418C16.2469 31.1874 16.5783 31.5164 16.5783 31.9277C16.5783 31.9976 16.5701 32.0675 16.5496 32.1334L15.7845 34.8109C15.7477 34.9549 15.7231 35.1029 15.7231 35.255C15.7231 36.1846 16.4719 36.9374 17.3965 36.9374L26.553 36.929C28.1446 36.929 29.4865 35.8513 29.8957 34.3833L31.0085 30.4841L31.0126 30.48Z" fill="#F2FF59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3062_2148">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -71,8 +71,18 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
type="image/svg+xml"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -356,10 +356,7 @@ export type {
|
||||
GetModelFoldersResponse,
|
||||
GetModelFoldersResponses,
|
||||
GetModelPreviewData,
|
||||
GetModelPreviewError,
|
||||
GetModelPreviewErrors,
|
||||
GetModelPreviewResponse,
|
||||
GetModelPreviewResponses,
|
||||
GetModelsInFolderData,
|
||||
GetModelsInFolderError,
|
||||
GetModelsInFolderErrors,
|
||||
@@ -389,8 +386,21 @@ export type {
|
||||
GetNodeReplacementsErrors,
|
||||
GetNodeReplacementsResponse,
|
||||
GetNodeReplacementsResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetOAuthAuthorizationServerData,
|
||||
GetOAuthAuthorizationServerError,
|
||||
GetOAuthAuthorizationServerErrors,
|
||||
GetOAuthAuthorizationServerResponse,
|
||||
GetOAuthAuthorizationServerResponses,
|
||||
GetOAuthAuthorizeData,
|
||||
GetOAuthAuthorizeError,
|
||||
GetOAuthAuthorizeErrors,
|
||||
GetOAuthAuthorizeResponse,
|
||||
GetOAuthAuthorizeResponses,
|
||||
GetOAuthProtectedResourceData,
|
||||
GetOAuthProtectedResourceError,
|
||||
GetOAuthProtectedResourceErrors,
|
||||
GetOAuthProtectedResourceResponse,
|
||||
GetOAuthProtectedResourceResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
@@ -427,11 +437,11 @@ export type {
|
||||
GetSecretErrors,
|
||||
GetSecretResponse,
|
||||
GetSecretResponses,
|
||||
GetSettingByKeyData,
|
||||
GetSettingByKeyError,
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetSettingByIdData,
|
||||
GetSettingByIdError,
|
||||
GetSettingByIdErrors,
|
||||
GetSettingByIdResponse,
|
||||
GetSettingByIdResponses,
|
||||
GetStaticExtensionsData,
|
||||
GetStaticExtensionsErrors,
|
||||
GetStaticExtensionsResponses,
|
||||
@@ -447,6 +457,7 @@ export type {
|
||||
GetTaskResponses,
|
||||
GetTemplateProxyData,
|
||||
GetTemplateProxyErrors,
|
||||
GetTemplateProxyResponses,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
@@ -534,6 +545,11 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InsertDynamicConfigData,
|
||||
InsertDynamicConfigError,
|
||||
InsertDynamicConfigErrors,
|
||||
InsertDynamicConfigResponse,
|
||||
InsertDynamicConfigResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
@@ -642,6 +658,17 @@ export type {
|
||||
MoveUserdataFileResponse,
|
||||
MoveUserdataFileResponses,
|
||||
NodeInfo,
|
||||
OAuthAuthorizationServerMetadata,
|
||||
OAuthAuthorizeRedirectResponse,
|
||||
OAuthConsentChallenge,
|
||||
OAuthConsentChallengeWorkspace,
|
||||
OAuthProtectedResourceMetadata,
|
||||
OAuthRegisterBadRequestResponse,
|
||||
OAuthRegisterError,
|
||||
OAuthRegisterRequest,
|
||||
OAuthRegisterResponse,
|
||||
OAuthTokenError,
|
||||
OAuthTokenResponse,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
@@ -663,6 +690,21 @@ export type {
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostOAuthAuthorizeData,
|
||||
PostOAuthAuthorizeError,
|
||||
PostOAuthAuthorizeErrors,
|
||||
PostOAuthAuthorizeResponse,
|
||||
PostOAuthAuthorizeResponses,
|
||||
PostOAuthRegisterData,
|
||||
PostOAuthRegisterError,
|
||||
PostOAuthRegisterErrors,
|
||||
PostOAuthRegisterResponse,
|
||||
PostOAuthRegisterResponses,
|
||||
PostOAuthTokenData,
|
||||
PostOAuthTokenError,
|
||||
PostOAuthTokenErrors,
|
||||
PostOAuthTokenResponse,
|
||||
PostOAuthTokenResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
@@ -799,11 +841,11 @@ export type {
|
||||
UpdateSecretRequest,
|
||||
UpdateSecretResponse,
|
||||
UpdateSecretResponses,
|
||||
UpdateSettingByKeyData,
|
||||
UpdateSettingByKeyError,
|
||||
UpdateSettingByKeyErrors,
|
||||
UpdateSettingByKeyResponse,
|
||||
UpdateSettingByKeyResponses,
|
||||
UpdateSettingByIdData,
|
||||
UpdateSettingByIdError,
|
||||
UpdateSettingByIdErrors,
|
||||
UpdateSettingByIdResponse,
|
||||
UpdateSettingByIdResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
|
||||
752
packages/ingest-types/src/types.gen.ts
generated
752
packages/ingest-types/src/types.gen.ts
generated
@@ -1382,6 +1382,250 @@ export type JwkKey = {
|
||||
y: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.2 error response.
|
||||
*/
|
||||
export type OAuthTokenError = {
|
||||
/**
|
||||
* RFC 6749 §5.2 error code: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope.
|
||||
*/
|
||||
error: string
|
||||
/**
|
||||
* Human-readable, no leak of internal storage state.
|
||||
*/
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.1 successful token response.
|
||||
*/
|
||||
export type OAuthTokenResponse = {
|
||||
/**
|
||||
* Resource-bound Cloud JWT (audience matches the protected resource).
|
||||
*/
|
||||
access_token: string
|
||||
token_type: 'Bearer'
|
||||
/**
|
||||
* Access token lifetime in seconds.
|
||||
*/
|
||||
expires_in: number
|
||||
/**
|
||||
* Opaque refresh token. Rotates on every successful refresh; presenting an already-rotated token revokes the entire family.
|
||||
*/
|
||||
refresh_token: string
|
||||
/**
|
||||
* Space-delimited scopes granted with this token.
|
||||
*/
|
||||
scope: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
|
||||
*
|
||||
*/
|
||||
export type OAuthConsentChallengeWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'personal' | 'team'
|
||||
role: 'owner' | 'member'
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
|
||||
*/
|
||||
export type OAuthAuthorizeRedirectResponse = {
|
||||
/**
|
||||
* OAuth client redirect URI with either code+state for allow, or error+state for deny.
|
||||
*/
|
||||
redirect_url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
|
||||
*
|
||||
*/
|
||||
export type OAuthConsentChallenge = {
|
||||
/**
|
||||
* Opaque server-side identifier for the authorization-request row. Carried back unchanged in the consent submission.
|
||||
*/
|
||||
oauth_request_id: string
|
||||
/**
|
||||
* Per-row CSRF token bound to this authorization request (not to the session). Must be echoed back on POST.
|
||||
*/
|
||||
csrf_token: string
|
||||
/**
|
||||
* Human-readable name of the OAuth client requesting authorization, from oauth_clients.display_name.
|
||||
*/
|
||||
client_display_name: string
|
||||
/**
|
||||
* Human-readable name of the protected resource, from oauth_resources.display_name.
|
||||
*/
|
||||
resource_display_name: string
|
||||
/**
|
||||
* Scopes the client is requesting for this resource. The frontend should present these for the user to approve.
|
||||
*/
|
||||
scopes: Array<string>
|
||||
/**
|
||||
* Workspaces the user can select from. Membership is re-checked on POST.
|
||||
*/
|
||||
workspaces: Array<OAuthConsentChallengeWorkspace>
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.1 protected-resource metadata (RFC 9728).
|
||||
*/
|
||||
export type OAuthProtectedResourceMetadata = {
|
||||
resource: string
|
||||
authorization_servers: Array<string>
|
||||
scopes_supported: Array<string>
|
||||
bearer_methods_supported?: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.2 error response.
|
||||
*/
|
||||
export type OAuthRegisterError = {
|
||||
error: 'invalid_redirect_uri' | 'invalid_client_metadata'
|
||||
error_description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
|
||||
*
|
||||
*/
|
||||
export type OAuthRegisterBadRequestResponse =
|
||||
| OAuthRegisterError
|
||||
| BindingErrorResponse
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export type BindingErrorResponse = {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.1 successful registration response.
|
||||
*/
|
||||
export type OAuthRegisterResponse = {
|
||||
/**
|
||||
* Server-generated client_id. Always carries the `comfy-dyn-` prefix.
|
||||
*/
|
||||
client_id: string
|
||||
/**
|
||||
* Unix timestamp (seconds) when the client was registered.
|
||||
*/
|
||||
client_id_issued_at: number
|
||||
client_name?: string
|
||||
redirect_uris: Array<string>
|
||||
grant_types: Array<string>
|
||||
response_types: Array<string>
|
||||
token_endpoint_auth_method: 'none'
|
||||
application_type: 'native' | 'web'
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
|
||||
*
|
||||
*/
|
||||
export type OAuthRegisterRequest = {
|
||||
/**
|
||||
* 1–5 redirect URIs. Validated against `application_type` policy.
|
||||
*/
|
||||
redirect_uris: Array<string>
|
||||
/**
|
||||
* Human-readable name shown in the consent UI. Reserved-name list rejects impersonation of major MCP clients.
|
||||
*/
|
||||
client_name?: string
|
||||
/**
|
||||
* RFC 7591 §2 application_type. **REQUIRED** — clients MUST declare intent; the server does not default this field. `native` for desktop / CLI / MCP-spec-strict clients (loopback redirects); `web` for hosted clients (HTTPS only, host must be allowlisted). A missing or explicitly empty `application_type` rejects with `invalid_client_metadata`. The realistic MCP-client population is overwhelmingly native/loopback — requiring explicit declaration avoids silently bouncing those clients off the web HTTPS policy.
|
||||
*
|
||||
*/
|
||||
application_type: 'native' | 'web'
|
||||
/**
|
||||
* Public clients only this phase — must be `none` if present. The server forces `none` regardless.
|
||||
*/
|
||||
token_endpoint_auth_method?: 'none'
|
||||
/**
|
||||
* Optional. Defaults to `["authorization_code","refresh_token"]`.
|
||||
*/
|
||||
grant_types?: Array<'authorization_code' | 'refresh_token'>
|
||||
/**
|
||||
* Optional. Defaults to `["code"]`.
|
||||
*/
|
||||
response_types?: Array<'code'>
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Dynamic clients do not pick scopes — the server assigns scopes from the active MCP resource's published list. Sending `scope` in the registration body is treated as a privilege-escalation attempt and returns `invalid_client_metadata`. The field is documented here so clients see a well-defined error rather than silent drop.
|
||||
*
|
||||
*/
|
||||
scope?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Same reason as `scope`. The set of resources and scopes a dynamic client may request is server-policy, not request-driven.
|
||||
*
|
||||
*/
|
||||
resource_grants?: {
|
||||
[key: string]: Array<string>
|
||||
} | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
client_uri?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
logo_uri?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
tos_uri?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
policy_uri?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
software_id?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
software_version?: string | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
contacts?: Array<string> | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
jwks?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
/**
|
||||
* **REJECTED IF PRESENT.** Unsupported RFC 7591 metadata for this public MCP-client phase.
|
||||
*/
|
||||
jwks_uri?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.1 authorization-server metadata (RFC 8414).
|
||||
*/
|
||||
export type OAuthAuthorizationServerMetadata = {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
jwks_uri: string
|
||||
/**
|
||||
* RFC 7591 §3.1 Dynamic Client Registration endpoint. Advertised so MCP-spec-compliant clients can auto-discover and self-register without operator involvement. Present only when DCR is enabled.
|
||||
*
|
||||
*/
|
||||
registration_endpoint?: string
|
||||
response_types_supported: Array<string>
|
||||
grant_types_supported: Array<string>
|
||||
code_challenge_methods_supported: Array<string>
|
||||
token_endpoint_auth_methods_supported: Array<string>
|
||||
scopes_supported?: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
|
||||
*/
|
||||
@@ -1531,6 +1775,10 @@ export type WorkspaceApiKeyInfo = {
|
||||
* User-provided label
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
|
||||
*/
|
||||
description: string
|
||||
/**
|
||||
* First 8 chars after prefix for display
|
||||
*/
|
||||
@@ -1565,6 +1813,10 @@ export type CreateWorkspaceApiKeyResponse = {
|
||||
* User-provided label
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
|
||||
*/
|
||||
description: string
|
||||
/**
|
||||
* The full plaintext API key (only shown once)
|
||||
*/
|
||||
@@ -1591,6 +1843,10 @@ export type CreateWorkspaceApiKeyRequest = {
|
||||
* User-provided label for the key
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* User-provided description of the key's purpose. Limit is byte-based (UTF-8 encoding); 5000 bytes equals 5000 ASCII characters or fewer multi-byte characters.
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Optional expiration timestamp
|
||||
*/
|
||||
@@ -2270,6 +2526,12 @@ export type ListAssetsResponse = {
|
||||
* Whether more assets are available beyond this page
|
||||
*/
|
||||
has_more: boolean
|
||||
/**
|
||||
* Opaque cursor to pass as the `after` query parameter to fetch the
|
||||
* next page. Omitted from the response when there are no more results.
|
||||
*
|
||||
*/
|
||||
next_cursor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2284,6 +2546,10 @@ export type Asset = {
|
||||
* Name of the asset file
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Display name of the asset. Mirrors name for backwards compatibility.
|
||||
*/
|
||||
display_name?: string | null
|
||||
/**
|
||||
* Blake3 hash of the asset content
|
||||
*/
|
||||
@@ -2360,6 +2626,10 @@ export type AssetUpdated = {
|
||||
* Updated name of the asset
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Display name of the asset. Mirrors name for backwards compatibility.
|
||||
*/
|
||||
display_name?: string | null
|
||||
/**
|
||||
* Blake3 hash of the asset content
|
||||
*/
|
||||
@@ -3035,13 +3305,6 @@ export type ExportDownloadUrlResponse = {
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export type BindingErrorResponse = {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
@@ -3124,6 +3387,12 @@ export type ListAssetsResponseWritable = {
|
||||
* Whether more assets are available beyond this page
|
||||
*/
|
||||
has_more: boolean
|
||||
/**
|
||||
* Opaque cursor to pass as the `after` query parameter to fetch the
|
||||
* next page. Omitted from the response when there are no more results.
|
||||
*
|
||||
*/
|
||||
next_cursor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3138,6 +3407,10 @@ export type AssetWritable = {
|
||||
* Name of the asset file
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Display name of the asset. Mirrors name for backwards compatibility.
|
||||
*/
|
||||
display_name?: string | null
|
||||
/**
|
||||
* Blake3 hash of the asset content
|
||||
*/
|
||||
@@ -3507,50 +3780,6 @@ export type GetModelsInFolderResponses = {
|
||||
export type GetModelsInFolderResponse =
|
||||
GetModelsInFolderResponses[keyof GetModelsInFolderResponses]
|
||||
|
||||
export type GetModelPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
/**
|
||||
* The folder name containing the model
|
||||
*/
|
||||
folder: string
|
||||
/**
|
||||
* The path index (usually 0 for cloud service)
|
||||
*/
|
||||
path_index: number
|
||||
/**
|
||||
* The model filename (with or without .webp extension)
|
||||
*/
|
||||
filename: string
|
||||
}
|
||||
query?: never
|
||||
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
|
||||
}
|
||||
|
||||
export type GetModelPreviewErrors = {
|
||||
/**
|
||||
* Model not found or preview not available
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetModelPreviewError =
|
||||
GetModelPreviewErrors[keyof GetModelPreviewErrors]
|
||||
|
||||
export type GetModelPreviewResponses = {
|
||||
/**
|
||||
* Success - Model preview image
|
||||
*/
|
||||
200: Blob | File
|
||||
}
|
||||
|
||||
export type GetModelPreviewResponse =
|
||||
GetModelPreviewResponses[keyof GetModelPreviewResponses]
|
||||
|
||||
export type GetLegacyHistoryData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -4012,10 +4241,6 @@ export type ListAssetsData = {
|
||||
* Sort order
|
||||
*/
|
||||
order?: 'asc' | 'desc'
|
||||
/**
|
||||
* Filter assets by job IDs (prompt IDs)
|
||||
*/
|
||||
job_ids?: Array<string>
|
||||
/**
|
||||
* Whether to include public/shared assets in results
|
||||
*/
|
||||
@@ -4024,6 +4249,17 @@ export type ListAssetsData = {
|
||||
* Filter assets by exact content hash
|
||||
*/
|
||||
asset_hash?: string
|
||||
/**
|
||||
* Opaque cursor for keyset pagination. Pass the `next_cursor` value
|
||||
* from the previous response to fetch the next page. When provided,
|
||||
* `offset` is ignored. Cursor pagination is only supported with
|
||||
* `sort` values `created_at`, `updated_at`, `name`, or `size`;
|
||||
* requests combining `after` with other sort fields return 400.
|
||||
* The cursor must have been minted under the same `sort` value used
|
||||
* in the follow-up request.
|
||||
*
|
||||
*/
|
||||
after?: string
|
||||
}
|
||||
url: '/api/assets'
|
||||
}
|
||||
@@ -4122,10 +4358,6 @@ export type UploadAssetErrors = {
|
||||
export type UploadAssetError = UploadAssetErrors[keyof UploadAssetErrors]
|
||||
|
||||
export type UploadAssetResponses = {
|
||||
/**
|
||||
* Asset already exists (returned existing asset)
|
||||
*/
|
||||
200: AssetCreated
|
||||
/**
|
||||
* Asset created successfully
|
||||
*/
|
||||
@@ -4188,10 +4420,6 @@ export type CreateAssetFromHashError =
|
||||
CreateAssetFromHashErrors[keyof CreateAssetFromHashErrors]
|
||||
|
||||
export type CreateAssetFromHashResponses = {
|
||||
/**
|
||||
* Asset reference already exists (returned existing)
|
||||
*/
|
||||
200: AssetCreated
|
||||
/**
|
||||
* Asset reference created successfully
|
||||
*/
|
||||
@@ -5345,19 +5573,19 @@ export type UpdateMultipleSettingsResponses = {
|
||||
export type UpdateMultipleSettingsResponse =
|
||||
UpdateMultipleSettingsResponses[keyof UpdateMultipleSettingsResponses]
|
||||
|
||||
export type GetSettingByKeyData = {
|
||||
export type GetSettingByIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
/**
|
||||
* Setting key to retrieve
|
||||
* Setting id to retrieve
|
||||
*/
|
||||
key: string
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/api/settings/{key}'
|
||||
url: '/api/settings/{id}'
|
||||
}
|
||||
|
||||
export type GetSettingByKeyErrors = {
|
||||
export type GetSettingByIdErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
@@ -5368,10 +5596,10 @@ export type GetSettingByKeyErrors = {
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetSettingByKeyError =
|
||||
GetSettingByKeyErrors[keyof GetSettingByKeyErrors]
|
||||
export type GetSettingByIdError =
|
||||
GetSettingByIdErrors[keyof GetSettingByIdErrors]
|
||||
|
||||
export type GetSettingByKeyResponses = {
|
||||
export type GetSettingByIdResponses = {
|
||||
/**
|
||||
* Setting value response
|
||||
*/
|
||||
@@ -5383,25 +5611,25 @@ export type GetSettingByKeyResponses = {
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSettingByKeyResponse =
|
||||
GetSettingByKeyResponses[keyof GetSettingByKeyResponses]
|
||||
export type GetSettingByIdResponse =
|
||||
GetSettingByIdResponses[keyof GetSettingByIdResponses]
|
||||
|
||||
export type UpdateSettingByKeyData = {
|
||||
export type UpdateSettingByIdData = {
|
||||
/**
|
||||
* New value for the setting
|
||||
*/
|
||||
body: unknown
|
||||
path: {
|
||||
/**
|
||||
* Setting key to update
|
||||
* Setting id to update
|
||||
*/
|
||||
key: string
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/api/settings/{key}'
|
||||
url: '/api/settings/{id}'
|
||||
}
|
||||
|
||||
export type UpdateSettingByKeyErrors = {
|
||||
export type UpdateSettingByIdErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
@@ -5412,10 +5640,10 @@ export type UpdateSettingByKeyErrors = {
|
||||
401: ErrorResponse
|
||||
}
|
||||
|
||||
export type UpdateSettingByKeyError =
|
||||
UpdateSettingByKeyErrors[keyof UpdateSettingByKeyErrors]
|
||||
export type UpdateSettingByIdError =
|
||||
UpdateSettingByIdErrors[keyof UpdateSettingByIdErrors]
|
||||
|
||||
export type UpdateSettingByKeyResponses = {
|
||||
export type UpdateSettingByIdResponses = {
|
||||
/**
|
||||
* Updated setting value response
|
||||
*/
|
||||
@@ -5427,8 +5655,8 @@ export type UpdateSettingByKeyResponses = {
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateSettingByKeyResponse =
|
||||
UpdateSettingByKeyResponses[keyof UpdateSettingByKeyResponses]
|
||||
export type UpdateSettingByIdResponse =
|
||||
UpdateSettingByIdResponses[keyof UpdateSettingByIdResponses]
|
||||
|
||||
export type SubmitFeedbackData = {
|
||||
body: FeedbackRequest
|
||||
@@ -5916,40 +6144,6 @@ export type UploadMaskResponses = {
|
||||
* Type of upload (e.g., "output")
|
||||
*/
|
||||
type?: string
|
||||
/**
|
||||
* Additional metadata for mask detection and re-editing
|
||||
*/
|
||||
metadata?: {
|
||||
/**
|
||||
* Whether this file is a mask
|
||||
*/
|
||||
is_mask?: boolean
|
||||
/**
|
||||
* Hash of the original unmasked image
|
||||
*/
|
||||
original_hash?: string
|
||||
/**
|
||||
* Type of mask (e.g., "painted_masked")
|
||||
*/
|
||||
mask_type?: string
|
||||
/**
|
||||
* Related mask layer files (if available)
|
||||
*/
|
||||
related_files?: {
|
||||
/**
|
||||
* Hash of the mask layer
|
||||
*/
|
||||
mask?: string
|
||||
/**
|
||||
* Hash of the paint layer
|
||||
*/
|
||||
paint?: string
|
||||
/**
|
||||
* Hash of the painted image
|
||||
*/
|
||||
painted?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6117,6 +6311,229 @@ export type GetJwksResponses = {
|
||||
|
||||
export type GetJwksResponse = GetJwksResponses[keyof GetJwksResponses]
|
||||
|
||||
export type GetOAuthAuthorizationServerData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/.well-known/oauth-authorization-server'
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizationServerErrors = {
|
||||
/**
|
||||
* OAuth disabled
|
||||
*/
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizationServerError =
|
||||
GetOAuthAuthorizationServerErrors[keyof GetOAuthAuthorizationServerErrors]
|
||||
|
||||
export type GetOAuthAuthorizationServerResponses = {
|
||||
/**
|
||||
* Authorization-server metadata
|
||||
*/
|
||||
200: OAuthAuthorizationServerMetadata
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizationServerResponse =
|
||||
GetOAuthAuthorizationServerResponses[keyof GetOAuthAuthorizationServerResponses]
|
||||
|
||||
export type GetOAuthProtectedResourceData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/.well-known/oauth-protected-resource'
|
||||
}
|
||||
|
||||
export type GetOAuthProtectedResourceErrors = {
|
||||
/**
|
||||
* OAuth disabled or no active resource configured
|
||||
*/
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetOAuthProtectedResourceError =
|
||||
GetOAuthProtectedResourceErrors[keyof GetOAuthProtectedResourceErrors]
|
||||
|
||||
export type GetOAuthProtectedResourceResponses = {
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
200: OAuthProtectedResourceMetadata
|
||||
}
|
||||
|
||||
export type GetOAuthProtectedResourceResponse =
|
||||
GetOAuthProtectedResourceResponses[keyof GetOAuthProtectedResourceResponses]
|
||||
|
||||
export type GetOAuthAuthorizeData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
response_type?: string
|
||||
client_id?: string
|
||||
redirect_uri?: string
|
||||
scope?: string
|
||||
/**
|
||||
* RFC 6749 §10.12 marks `state` as RECOMMENDED. Our hardening makes
|
||||
* it REQUIRED on the initial-entry path (omitted only on the resume
|
||||
* path where `oauth_request_id` is supplied instead). This parameter
|
||||
* is `required: false` at the spec level only because the operation
|
||||
* is dual-mode (initial entry vs. resume); the runtime parser
|
||||
* (services/ingest/server/implementation/oauth/protocol/request.go)
|
||||
* rejects empty `state` on the initial-entry path with a stable
|
||||
* `invalid_request` 400.
|
||||
*
|
||||
*/
|
||||
state?: string
|
||||
code_challenge?: string
|
||||
code_challenge_method?: string
|
||||
resource?: string
|
||||
oauth_request_id?: string
|
||||
}
|
||||
url: '/oauth/authorize'
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizeErrors = {
|
||||
/**
|
||||
* Invalid authorize request (pre-redirect failure — unknown client, redirect mismatch, malformed params)
|
||||
*/
|
||||
400: ErrorResponse
|
||||
/**
|
||||
* OAuth disabled
|
||||
*/
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizeError =
|
||||
GetOAuthAuthorizeErrors[keyof GetOAuthAuthorizeErrors]
|
||||
|
||||
export type GetOAuthAuthorizeResponses = {
|
||||
/**
|
||||
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
|
||||
*
|
||||
*/
|
||||
200: OAuthConsentChallenge
|
||||
}
|
||||
|
||||
export type GetOAuthAuthorizeResponse =
|
||||
GetOAuthAuthorizeResponses[keyof GetOAuthAuthorizeResponses]
|
||||
|
||||
export type PostOAuthAuthorizeData = {
|
||||
body: {
|
||||
oauth_request_id: string
|
||||
csrf_token: string
|
||||
decision: 'allow' | 'deny'
|
||||
workspace_id: string
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/oauth/authorize'
|
||||
}
|
||||
|
||||
export type PostOAuthAuthorizeErrors = {
|
||||
/**
|
||||
* Bad request (CSRF mismatch, expired/consumed request, inaccessible workspace)
|
||||
*/
|
||||
400: ErrorResponse
|
||||
/**
|
||||
* Scope broadening on consent re-grant — fresh consent flow required
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
* OAuth disabled
|
||||
*/
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type PostOAuthAuthorizeError =
|
||||
PostOAuthAuthorizeErrors[keyof PostOAuthAuthorizeErrors]
|
||||
|
||||
export type PostOAuthAuthorizeResponses = {
|
||||
/**
|
||||
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
|
||||
*/
|
||||
200: OAuthAuthorizeRedirectResponse
|
||||
}
|
||||
|
||||
export type PostOAuthAuthorizeResponse =
|
||||
PostOAuthAuthorizeResponses[keyof PostOAuthAuthorizeResponses]
|
||||
|
||||
export type PostOAuthTokenData = {
|
||||
body: {
|
||||
grant_type: 'authorization_code' | 'refresh_token'
|
||||
client_id: string
|
||||
code?: string
|
||||
redirect_uri?: string
|
||||
code_verifier?: string
|
||||
refresh_token?: string
|
||||
scope?: string
|
||||
client_secret?: string
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/oauth/token'
|
||||
}
|
||||
|
||||
export type PostOAuthTokenErrors = {
|
||||
/**
|
||||
* RFC 6749 §5.2 error
|
||||
*/
|
||||
400: OAuthTokenError
|
||||
/**
|
||||
* OAuth disabled
|
||||
*/
|
||||
404: ErrorResponse
|
||||
}
|
||||
|
||||
export type PostOAuthTokenError =
|
||||
PostOAuthTokenErrors[keyof PostOAuthTokenErrors]
|
||||
|
||||
export type PostOAuthTokenResponses = {
|
||||
/**
|
||||
* New token pair
|
||||
*/
|
||||
200: OAuthTokenResponse
|
||||
}
|
||||
|
||||
export type PostOAuthTokenResponse =
|
||||
PostOAuthTokenResponses[keyof PostOAuthTokenResponses]
|
||||
|
||||
export type PostOAuthRegisterData = {
|
||||
body: OAuthRegisterRequest
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/oauth/register'
|
||||
}
|
||||
|
||||
export type PostOAuthRegisterErrors = {
|
||||
/**
|
||||
* Bad request. Two shapes possible: `OAuthRegisterError` (RFC 7591 §3.2.2, emitted by the handler for invalid client metadata, missing application_type, reserved client_name, etc.) OR `BindingErrorResponse` (emitted by the strict-server binding layer when the request body fails OpenAPI-schema validation — malformed JSON, missing required fields, `additionalProperties: false` violations).
|
||||
*
|
||||
*/
|
||||
400: OAuthRegisterBadRequestResponse
|
||||
/**
|
||||
* OAuth disabled
|
||||
*/
|
||||
404: ErrorResponse
|
||||
/**
|
||||
* No active MCP resource is configured — DCR cannot mint a usable client until ops seeds an active oauth_resources row.
|
||||
*/
|
||||
503: ErrorResponse
|
||||
}
|
||||
|
||||
export type PostOAuthRegisterError =
|
||||
PostOAuthRegisterErrors[keyof PostOAuthRegisterErrors]
|
||||
|
||||
export type PostOAuthRegisterResponses = {
|
||||
/**
|
||||
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
|
||||
*/
|
||||
201: OAuthRegisterResponse
|
||||
}
|
||||
|
||||
export type PostOAuthRegisterResponse =
|
||||
PostOAuthRegisterResponses[keyof PostOAuthRegisterResponses]
|
||||
|
||||
export type ListWorkspacesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -6679,7 +7096,7 @@ export type CreateWorkspaceApiKeyErrors = {
|
||||
*/
|
||||
401: ErrorResponse
|
||||
/**
|
||||
* Not a workspace member or personal workspace
|
||||
* Not a workspace member
|
||||
*/
|
||||
403: ErrorResponse
|
||||
/**
|
||||
@@ -7140,6 +7557,51 @@ export type UpdateSubscriptionCacheResponses = {
|
||||
export type UpdateSubscriptionCacheResponse =
|
||||
UpdateSubscriptionCacheResponses[keyof UpdateSubscriptionCacheResponses]
|
||||
|
||||
export type InsertDynamicConfigData = {
|
||||
/**
|
||||
* A valid dynamicconfig.Config JSON object.
|
||||
*/
|
||||
body: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/admin/api/dynamic-config'
|
||||
}
|
||||
|
||||
export type InsertDynamicConfigErrors = {
|
||||
/**
|
||||
* Invalid or missing request body
|
||||
*/
|
||||
400: ErrorResponse
|
||||
/**
|
||||
* Database insert failed
|
||||
*/
|
||||
500: ErrorResponse
|
||||
}
|
||||
|
||||
export type InsertDynamicConfigError =
|
||||
InsertDynamicConfigErrors[keyof InsertDynamicConfigErrors]
|
||||
|
||||
export type InsertDynamicConfigResponses = {
|
||||
/**
|
||||
* Config inserted successfully
|
||||
*/
|
||||
201: {
|
||||
/**
|
||||
* The database ID of the newly inserted config row.
|
||||
*/
|
||||
id?: number
|
||||
/**
|
||||
* Human-readable success message.
|
||||
*/
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type InsertDynamicConfigResponse =
|
||||
InsertDynamicConfigResponses[keyof InsertDynamicConfigResponses]
|
||||
|
||||
export type SyncApiKeyData = {
|
||||
body: SyncApiKeyRequest
|
||||
path?: never
|
||||
@@ -8888,9 +9350,20 @@ export type GetTemplateProxyData = {
|
||||
|
||||
export type GetTemplateProxyErrors = {
|
||||
/**
|
||||
* Template not found
|
||||
* Template not found.
|
||||
*/
|
||||
404: unknown
|
||||
/**
|
||||
* Workflow templates version not available.
|
||||
*/
|
||||
503: unknown
|
||||
}
|
||||
|
||||
export type GetTemplateProxyResponses = {
|
||||
/**
|
||||
* Template file content streamed from GCS.
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetHealthData = {
|
||||
@@ -8918,20 +9391,6 @@ export type GetHealthResponses = {
|
||||
|
||||
export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]
|
||||
|
||||
export type GetOpenapiSpecData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/openapi'
|
||||
}
|
||||
|
||||
export type GetOpenapiSpecResponses = {
|
||||
/**
|
||||
* OpenAPI specification document
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -9194,6 +9653,33 @@ export type PostCustomNodeProxyResponses = {
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetModelPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
/**
|
||||
* The folder name containing the model.
|
||||
*/
|
||||
folder: string
|
||||
/**
|
||||
* The path index (usually 0 for cloud service).
|
||||
*/
|
||||
path_index: number
|
||||
/**
|
||||
* The model filename (with or without .webp extension).
|
||||
*/
|
||||
filename: string
|
||||
}
|
||||
query?: never
|
||||
url: '/api/experiment/models/preview/{folder}/{path_index}/{filename}'
|
||||
}
|
||||
|
||||
export type GetModelPreviewErrors = {
|
||||
/**
|
||||
* Preview not available on Cloud
|
||||
*/
|
||||
404: unknown
|
||||
}
|
||||
|
||||
export type GetLegacyPromptByIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
350
packages/ingest-types/src/zod.gen.ts
generated
350
packages/ingest-types/src/zod.gen.ts
generated
@@ -879,6 +879,153 @@ export const zJwkKey = z.object({
|
||||
y: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.2 error response.
|
||||
*/
|
||||
export const zOAuthTokenError = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 6749 §5.1 successful token response.
|
||||
*/
|
||||
export const zOAuthTokenResponse = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.enum(['Bearer']),
|
||||
expires_in: z.number().int(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* One workspace option presented in the OAuth consent challenge. Promoted to a named schema so the generated Go type is referenceable in handlers and tests rather than re-declared as an anonymous struct at every callsite.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallengeWorkspace = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['personal', 'team']),
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect target produced after a JSON consent submission. The frontend must navigate the browser to this URL so custom-scheme client callbacks work without relying on fetch-visible 302 headers.
|
||||
*/
|
||||
export const zOAuthAuthorizeRedirectResponse = z.object({
|
||||
redirect_url: z.string().url()
|
||||
})
|
||||
|
||||
/**
|
||||
* Server-side state describing the OAuth consent decision the user is being asked to make. Returned by GET /oauth/authorize when a valid Cloud session exists; the frontend renders the consent UI from this payload and POSTs the decision back. Browser never sees the original OAuth params on resume.
|
||||
*
|
||||
*/
|
||||
export const zOAuthConsentChallenge = z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 protected-resource metadata (RFC 9728).
|
||||
*/
|
||||
export const zOAuthProtectedResourceMetadata = z.object({
|
||||
resource: z.string().url(),
|
||||
authorization_servers: z.array(z.string().url()),
|
||||
scopes_supported: z.array(z.string()),
|
||||
bearer_methods_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.2 error response.
|
||||
*/
|
||||
export const zOAuthRegisterError = z.object({
|
||||
error: z.enum(['invalid_redirect_uri', 'invalid_client_metadata']),
|
||||
error_description: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Union of the two 400 shapes /oauth/register can emit. `OAuthRegisterError` is the handler-shaped RFC 7591 §3.2.2 error; `BindingErrorResponse` is the strict-server binding-layer error fired when the request body fails OpenAPI-schema validation before the handler runs.
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterBadRequestResponse = z.union([
|
||||
zOAuthRegisterError,
|
||||
zBindingErrorResponse
|
||||
])
|
||||
|
||||
/**
|
||||
* RFC 7591 §3.2.1 successful registration response.
|
||||
*/
|
||||
export const zOAuthRegisterResponse = z.object({
|
||||
client_id: z.string(),
|
||||
client_id_issued_at: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
client_name: z.string().optional(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.enum(['none']),
|
||||
application_type: z.enum(['native', 'web'])
|
||||
})
|
||||
|
||||
/**
|
||||
* RFC 7591 §2 client metadata document. Only the fields the server honors are listed; presence of `scope` or `resource_grants` in the request is rejected (`invalid_client_metadata`) because those are server-owned for dynamic clients. `additionalProperties: false` mirrors the runtime middleware that rejects any unknown metadata key.
|
||||
*
|
||||
*/
|
||||
export const zOAuthRegisterRequest = z.object({
|
||||
redirect_uris: z.array(z.string()).min(1).max(5),
|
||||
client_name: z.string().max(100).optional(),
|
||||
application_type: z.enum(['native', 'web']),
|
||||
token_endpoint_auth_method: z.enum(['none']).optional(),
|
||||
grant_types: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token']))
|
||||
.optional(),
|
||||
response_types: z.array(z.enum(['code'])).optional(),
|
||||
scope: z.string().nullish(),
|
||||
resource_grants: z.record(z.array(z.string())).nullish(),
|
||||
client_uri: z.string().nullish(),
|
||||
logo_uri: z.string().nullish(),
|
||||
tos_uri: z.string().nullish(),
|
||||
policy_uri: z.string().nullish(),
|
||||
software_id: z.string().nullish(),
|
||||
software_version: z.string().nullish(),
|
||||
contacts: z.array(z.string()).nullish(),
|
||||
jwks: z.record(z.unknown()).nullish(),
|
||||
jwks_uri: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* OAuth 2.1 authorization-server metadata (RFC 8414).
|
||||
*/
|
||||
export const zOAuthAuthorizationServerMetadata = z.object({
|
||||
issuer: z.string().url(),
|
||||
authorization_endpoint: z.string().url(),
|
||||
token_endpoint: z.string().url(),
|
||||
jwks_uri: z.string().url(),
|
||||
registration_endpoint: z.string().url().optional(),
|
||||
response_types_supported: z.array(z.string()),
|
||||
grant_types_supported: z.array(z.string()),
|
||||
code_challenge_methods_supported: z.array(z.string()),
|
||||
token_endpoint_auth_methods_supported: z.array(z.string()),
|
||||
scopes_supported: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* JSON Web Key Set containing the public keys used to verify Cloud JWTs.
|
||||
*/
|
||||
@@ -940,6 +1087,7 @@ export const zWorkspaceApiKeyInfo = z.object({
|
||||
workspace_id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
last_used_at: z.string().datetime().optional(),
|
||||
@@ -960,6 +1108,7 @@ export const zListWorkspaceApiKeysResponse = z.object({
|
||||
export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().max(5000),
|
||||
key: z.string(),
|
||||
key_prefix: z.string(),
|
||||
expires_at: z.string().datetime().optional(),
|
||||
@@ -971,6 +1120,7 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
|
||||
*/
|
||||
export const zCreateWorkspaceApiKeyRequest = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().max(5000).optional(),
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
@@ -1353,6 +1503,7 @@ export const zListTagsResponse = z.object({
|
||||
export const zAsset = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
display_name: z.string().nullish(),
|
||||
asset_hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
@@ -1385,7 +1536,8 @@ export const zAsset = z.object({
|
||||
export const zListAssetsResponse = z.object({
|
||||
assets: z.array(zAsset),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1394,6 +1546,7 @@ export const zListAssetsResponse = z.object({
|
||||
export const zAssetUpdated = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().optional(),
|
||||
display_name: z.string().nullish(),
|
||||
asset_hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
@@ -1753,13 +1906,6 @@ export const zExportDownloadUrlResponse = z.object({
|
||||
expires_at: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Error shape returned when request binding or validation fails before the handler runs.
|
||||
*/
|
||||
export const zBindingErrorResponse = z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Standard error response with a machine-readable code and human-readable message.
|
||||
*/
|
||||
@@ -1796,6 +1942,7 @@ export const zPromptRequest = z.object({
|
||||
export const zAssetWritable = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
display_name: z.string().nullish(),
|
||||
asset_hash: z
|
||||
.string()
|
||||
.regex(/^blake3:[a-f0-9]{64}$/)
|
||||
@@ -1827,7 +1974,8 @@ export const zAssetWritable = z.object({
|
||||
export const zListAssetsResponseWritable = z.object({
|
||||
assets: z.array(zAssetWritable),
|
||||
total: z.number().int(),
|
||||
has_more: z.boolean()
|
||||
has_more: z.boolean(),
|
||||
next_cursor: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2109,6 @@ export const zGetModelsInFolderData = z.object({
|
||||
*/
|
||||
export const zGetModelsInFolderResponse = z.array(zModelFile)
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - Model preview image
|
||||
*/
|
||||
export const zGetModelPreviewResponse = z.string()
|
||||
|
||||
export const zGetLegacyHistoryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2132,9 +2265,9 @@ export const zListAssetsData = z.object({
|
||||
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
|
||||
.optional(),
|
||||
order: z.enum(['asc', 'desc']).optional(),
|
||||
job_ids: z.array(z.string().uuid()).optional(),
|
||||
include_public: z.boolean().optional().default(true),
|
||||
asset_hash: z.string().optional()
|
||||
asset_hash: z.string().optional(),
|
||||
after: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -2157,7 +2290,7 @@ export const zUploadAssetData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset already exists (returned existing asset)
|
||||
* Asset created successfully
|
||||
*/
|
||||
export const zUploadAssetResponse = zAssetCreated
|
||||
|
||||
@@ -2174,7 +2307,7 @@ export const zCreateAssetFromHashData = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset reference already exists (returned existing)
|
||||
* Asset reference created successfully
|
||||
*/
|
||||
export const zCreateAssetFromHashResponse = zAssetCreated
|
||||
|
||||
@@ -2509,10 +2642,10 @@ export const zUpdateMultipleSettingsData = z.object({
|
||||
*/
|
||||
export const zUpdateMultipleSettingsResponse = z.record(z.unknown())
|
||||
|
||||
export const zGetSettingByKeyData = z.object({
|
||||
export const zGetSettingByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2520,14 +2653,14 @@ export const zGetSettingByKeyData = z.object({
|
||||
/**
|
||||
* Setting value response
|
||||
*/
|
||||
export const zGetSettingByKeyResponse = z.object({
|
||||
export const zGetSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const zUpdateSettingByKeyData = z.object({
|
||||
export const zUpdateSettingByIdData = z.object({
|
||||
body: z.unknown(),
|
||||
path: z.object({
|
||||
key: z.string()
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
@@ -2535,7 +2668,7 @@ export const zUpdateSettingByKeyData = z.object({
|
||||
/**
|
||||
* Updated setting value response
|
||||
*/
|
||||
export const zUpdateSettingByKeyResponse = z.object({
|
||||
export const zUpdateSettingByIdResponse = z.object({
|
||||
value: z.unknown().optional()
|
||||
})
|
||||
|
||||
@@ -2691,21 +2824,7 @@ export const zUploadMaskData = z.object({
|
||||
export const zUploadMaskResponse = z.object({
|
||||
name: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
is_mask: z.boolean().optional(),
|
||||
original_hash: z.string().optional(),
|
||||
mask_type: z.string().optional(),
|
||||
related_files: z
|
||||
.object({
|
||||
mask: z.string().optional(),
|
||||
paint: z.string().optional(),
|
||||
painted: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
type: z.string().optional()
|
||||
})
|
||||
|
||||
export const zGetLogsData = z.object({
|
||||
@@ -2774,6 +2893,101 @@ export const zGetJwksData = z.object({
|
||||
*/
|
||||
export const zGetJwksResponse = zJwksResponse
|
||||
|
||||
export const zGetOAuthAuthorizationServerData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Authorization-server metadata
|
||||
*/
|
||||
export const zGetOAuthAuthorizationServerResponse =
|
||||
zOAuthAuthorizationServerMetadata
|
||||
|
||||
export const zGetOAuthProtectedResourceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Protected-resource metadata
|
||||
*/
|
||||
export const zGetOAuthProtectedResourceResponse =
|
||||
zOAuthProtectedResourceMetadata
|
||||
|
||||
export const zGetOAuthAuthorizeData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
response_type: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.string().optional(),
|
||||
resource: z.string().optional(),
|
||||
oauth_request_id: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Consent challenge payload (cookie present, email verified). Frontend renders the consent UI from this payload and POSTs back to /oauth/authorize.
|
||||
*
|
||||
*/
|
||||
export const zGetOAuthAuthorizeResponse = zOAuthConsentChallenge
|
||||
|
||||
export const zPostOAuthAuthorizeData = z.object({
|
||||
body: z.object({
|
||||
oauth_request_id: z.string().uuid(),
|
||||
csrf_token: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
workspace_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Redirect URL for the frontend to navigate to (allow → with code+state; deny → with error+state)
|
||||
*/
|
||||
export const zPostOAuthAuthorizeResponse = zOAuthAuthorizeRedirectResponse
|
||||
|
||||
export const zPostOAuthTokenData = z.object({
|
||||
body: z.object({
|
||||
grant_type: z.enum(['authorization_code', 'refresh_token']),
|
||||
client_id: z.string(),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
client_secret: z.string().optional()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* New token pair
|
||||
*/
|
||||
export const zPostOAuthTokenResponse = zOAuthTokenResponse
|
||||
|
||||
export const zPostOAuthRegisterData = z.object({
|
||||
body: zOAuthRegisterRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Registered. Body echoes the metadata RFC 7591 §3.2.1 requires.
|
||||
*/
|
||||
export const zPostOAuthRegisterResponse = zOAuthRegisterResponse
|
||||
|
||||
export const zListWorkspacesData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3078,6 +3292,28 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Config inserted successfully
|
||||
*/
|
||||
export const zInsertDynamicConfigResponse = z.object({
|
||||
id: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
export const zSyncApiKeyData = z.object({
|
||||
body: zSyncApiKeyRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3671,12 +3907,6 @@ export const zGetHealthData = z.object({
|
||||
*/
|
||||
export const zGetHealthResponse = z.string()
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3757,6 +3987,16 @@ export const zPostCustomNodeProxyData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetModelPreviewData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
folder: z.string(),
|
||||
path_index: z.number().int(),
|
||||
filename: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:aria-label="$t('load3d.switchCamera')"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i :class="cn('pi pi-camera text-lg text-base-foreground')" />
|
||||
<i class="pi pi-camera text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Prompt has no outputs').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
|
||||
@@ -29,17 +29,47 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
te: vi.fn(() => false),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback),
|
||||
t: vi.fn((key: string, params?: { count?: number }) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = params?.count ?? 0
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
return key
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'errorCatalog.validationErrors.required_input_missing.title':
|
||||
'Missing connection',
|
||||
'errorCatalog.validationErrors.required_input_missing.message':
|
||||
'Required input slots have no connection feeding them.',
|
||||
'errorCatalog.validationErrors.required_input_missing.details':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.itemLabel':
|
||||
'{nodeName} - {inputName}',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastTitle':
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
|
||||
const interpolate = (
|
||||
message: string,
|
||||
params?: Record<string, string | number>
|
||||
) =>
|
||||
message.replace(/\{(\w+)\}/g, (match, paramName) =>
|
||||
params?.[paramName] === undefined ? match : String(params[paramName])
|
||||
)
|
||||
|
||||
return {
|
||||
te: vi.fn((key: string) => key in messages),
|
||||
st: vi.fn((key: string, fallback: string) => messages[key] ?? fallback),
|
||||
t: vi.fn((key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'errorOverlay.missingModels') {
|
||||
const count = Number(params?.count ?? 0)
|
||||
return `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
}
|
||||
|
||||
return interpolate(messages[key] ?? key, params)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: () => ({
|
||||
@@ -412,10 +442,16 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
if (execGroups[0].type !== 'execution') return
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBe('KSampler')
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBe(
|
||||
'KSampler failed'
|
||||
)
|
||||
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
|
||||
message: 'RuntimeError: CUDA out of memory',
|
||||
details: 'line 1\nline 2',
|
||||
isRuntimeError: true,
|
||||
exceptionType: 'RuntimeError'
|
||||
})
|
||||
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
|
||||
// bypass catalog display fields until targeted runtime handling lands.
|
||||
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
|
||||
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes prompt error when present', async () => {
|
||||
@@ -428,7 +464,8 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'No outputs'
|
||||
(g) =>
|
||||
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
|
||||
)
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -417,12 +417,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const resolvedDisplay = resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: e.node_type,
|
||||
isCloud
|
||||
})
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
@@ -433,8 +427,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolvedDisplay
|
||||
exceptionType: e.exception_type
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
|
||||
@@ -238,7 +238,6 @@ import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContex
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useFlatOutputAssetsGrouped } from '@/platform/assets/composables/media/useFlatOutputAssetsGrouped'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -312,7 +311,7 @@ const formattedExecutionTime = computed(() => {
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useAssetsApi('input')
|
||||
const outputAssets = useFlatOutputAssetsGrouped()
|
||||
const outputAssets = useAssetsApi('output')
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
|
||||
@@ -566,12 +566,6 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles whether `_loadModelInternal` preserves the current camera state
|
||||
* across model loads. When enabled and a model has previously loaded, the
|
||||
* camera position/target/zoom (and camera type) are captured before the
|
||||
* scene clears and restored after the new model is in place.
|
||||
*/
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
@@ -581,9 +575,7 @@ class Load3d {
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// Retain view only kicks in after a successful first load — on the very
|
||||
// first load there's no meaningful "current" framing to preserve, so the
|
||||
// default `setupForModel` framing wins.
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
@@ -609,8 +601,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
if (savedCameraState) {
|
||||
// SceneModelManager.setupModel called setupForModel which clobbered the
|
||||
// camera. Restore the captured state on top of that.
|
||||
// setupForModel runs during loadModel and clobbers the camera; restore on top.
|
||||
if (
|
||||
savedCameraState.cameraType !==
|
||||
this.cameraManager.getCurrentCameraType()
|
||||
|
||||
@@ -3668,28 +3668,145 @@
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Required input missing",
|
||||
"toastMessage": "{nodeName} is missing a required input: {inputName}"
|
||||
}
|
||||
},
|
||||
"runtimeErrors": {
|
||||
"execution_failed": {
|
||||
},
|
||||
"bad_linked_input": {
|
||||
"title": "Invalid connection",
|
||||
"message": "A node connection could not be read correctly.",
|
||||
"details": "{nodeName} has an invalid connection for {inputName}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an invalid connection for {inputName}."
|
||||
},
|
||||
"return_type_mismatch": {
|
||||
"title": "Invalid connection",
|
||||
"message": "Connected nodes are using incompatible input and output types.",
|
||||
"details": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"detailsWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid connection",
|
||||
"toastMessage": "{nodeName} has an incompatible connection for {inputName}.",
|
||||
"toastMessageWithTypes": "{nodeName}'s {inputName} input expects {expectedType}, but the connected output is {receivedType}."
|
||||
},
|
||||
"invalid_input_type": {
|
||||
"title": "Invalid input",
|
||||
"message": "An input value has the wrong type.",
|
||||
"details": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} couldn't convert {inputName} to the expected type.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} couldn't be converted to {expectedType}."
|
||||
},
|
||||
"value_smaller_than_min": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value below the minimum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is below the minimum {minValue}."
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"title": "Input out of range",
|
||||
"message": "Some input values are outside the allowed range.",
|
||||
"details": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Input out of range",
|
||||
"toastMessage": "{nodeName} has a value above the maximum for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is above the maximum {maxValue}."
|
||||
},
|
||||
"value_not_in_list": {
|
||||
"title": "Invalid input",
|
||||
"message": "Some input values are not available for this node.",
|
||||
"details": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"detailsWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available.",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} has an unsupported value for {inputName}.",
|
||||
"toastMessageWithValue": "The value {receivedValue} for {nodeName}'s {inputName} is not available."
|
||||
},
|
||||
"custom_validation_failed": {
|
||||
"title": "Invalid input",
|
||||
"message": "A node rejected one or more input values.",
|
||||
"details": "{nodeName} rejected the value for {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} failed custom validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} rejected the value for {inputName}."
|
||||
},
|
||||
"exception_during_inner_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow couldn't validate a connected node.",
|
||||
"details": "{nodeName} couldn't validate {inputName}.",
|
||||
"detailsWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}",
|
||||
"itemLabel": "{nodeName} - {inputName}",
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} couldn't validate {inputName}.",
|
||||
"toastMessageWithRawDetails": "{nodeName} couldn't validate {inputName}: {rawDetails}"
|
||||
},
|
||||
"exception_during_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow could not be validated because a node validation check failed unexpectedly.",
|
||||
"details": "{nodeName} failed during validation.",
|
||||
"detailsWithRawDetails": "{nodeName} failed during validation: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "{nodeName} failed",
|
||||
"toastMessageLocal": "This node threw an error during execution. Check its inputs or try a different configuration.",
|
||||
"toastMessageCloud": "This node threw an error during execution. Check its inputs or try a different configuration. No credits charged."
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} failed during validation.",
|
||||
"toastMessageWithRawDetails": "{nodeName} failed during validation: {rawDetails}"
|
||||
},
|
||||
"dependency_cycle": {
|
||||
"title": "Invalid workflow",
|
||||
"message": "The workflow has a circular node connection.",
|
||||
"details": "{nodeName} is part of a circular connection.",
|
||||
"detailsWithRawDetails": "{nodeName} is part of a circular connection: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Invalid workflow",
|
||||
"toastMessage": "{nodeName} is part of a circular connection."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"message": "The system couldn't load this image.",
|
||||
"details": "The image for {nodeName} couldn't be loaded. Try adding it again.",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Input image couldn't be loaded",
|
||||
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"title": "Prompt has no outputs",
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"title": "Workflow data is empty",
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
},
|
||||
"server_error_local": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please check the server logs."
|
||||
},
|
||||
"server_error_cloud": {
|
||||
"title": "Server error",
|
||||
"desc": "The server encountered an unexpected error. Please try again later."
|
||||
},
|
||||
"missing_node_type": {
|
||||
"title": "Missing node type",
|
||||
"desc": "A node type is missing or unavailable. The workflow may be corrupted or require a custom node."
|
||||
},
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"image_not_loaded": {
|
||||
"title": "Image not loaded",
|
||||
"desc": "The system couldn't load this image."
|
||||
},
|
||||
"out_of_memory": {
|
||||
"title": "Generation failed",
|
||||
"descLocal": "Not enough GPU memory. Try reducing complexity and run again.",
|
||||
"descCloud": "Not enough GPU memory. Try reducing complexity and run again. No credits charged."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { useFlatOutputAssetsGrouped } from './useFlatOutputAssetsGrouped'
|
||||
|
||||
const mediaRef: Ref<AssetItem[]> = ref([])
|
||||
|
||||
vi.mock('./useFlatOutputAssets', () => ({
|
||||
useFlatOutputAssets: () => ({
|
||||
media: mediaRef,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false),
|
||||
fetchMediaList: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
loadMore: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
function asset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-id',
|
||||
name: 'output.png',
|
||||
tags: ['output'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFlatOutputAssetsGrouped', () => {
|
||||
it('collapses rows with the same job_id into a single representative', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', name: 'out1.png', job_id: 'job-1' }),
|
||||
asset({ id: 'b', name: 'out2.png', job_id: 'job-1' }),
|
||||
asset({ id: 'c', name: 'out3.png', job_id: 'job-1' }),
|
||||
asset({ id: 'd', name: 'solo.png', job_id: 'job-2' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['a', 'd'])
|
||||
})
|
||||
|
||||
it('exposes the group size as user_metadata.outputCount', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', job_id: 'job-1' }),
|
||||
asset({ id: 'b', job_id: 'job-1' }),
|
||||
asset({ id: 'c', job_id: 'job-1' }),
|
||||
asset({ id: 'd', job_id: 'job-2' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value[0].user_metadata?.outputCount).toBe(3)
|
||||
expect(media.value[0].user_metadata?.jobId).toBe('job-1')
|
||||
expect(media.value[1].user_metadata?.outputCount).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to prompt_id when job_id is absent (legacy)', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', prompt_id: 'job-legacy' }),
|
||||
asset({ id: 'b', prompt_id: 'job-legacy' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value).toHaveLength(1)
|
||||
expect(media.value[0].user_metadata?.jobId).toBe('job-legacy')
|
||||
expect(media.value[0].user_metadata?.outputCount).toBe(2)
|
||||
})
|
||||
|
||||
it('passes through rows that have neither job_id nor prompt_id', () => {
|
||||
mediaRef.value = [asset({ id: 'orphan-a' }), asset({ id: 'orphan-b' })]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['orphan-a', 'orphan-b'])
|
||||
})
|
||||
|
||||
it('preserves the order of the first occurrence per job_id', () => {
|
||||
mediaRef.value = [
|
||||
asset({ id: 'a', job_id: 'job-A' }),
|
||||
asset({ id: 'b', job_id: 'job-B' }),
|
||||
asset({ id: 'c', job_id: 'job-A' }),
|
||||
asset({ id: 'd', job_id: 'job-C' })
|
||||
]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
|
||||
expect(media.value.map((a) => a.id)).toEqual(['a', 'b', 'd'])
|
||||
})
|
||||
|
||||
it('does not mutate the underlying assets', () => {
|
||||
const original = asset({ id: 'a', job_id: 'job-1' })
|
||||
mediaRef.value = [original, asset({ id: 'b', job_id: 'job-1' })]
|
||||
|
||||
const { media } = useFlatOutputAssetsGrouped()
|
||||
void media.value
|
||||
|
||||
expect(original.user_metadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useFlatOutputAssets } from './useFlatOutputAssets'
|
||||
|
||||
/**
|
||||
* Cloud `/api/assets?include_tags=output` returns one row per individual output
|
||||
* file. The asset sidebar's stack UX expects one card per job with an
|
||||
* `outputCount` badge, so collapse rows that share a `job_id` into a single
|
||||
* representative (the first occurrence — assets are returned newest-first).
|
||||
*
|
||||
* The siblings remain reachable through the existing stack-expand path via
|
||||
* `resolveOutputAssetItems(metadata)`.
|
||||
*/
|
||||
export function useFlatOutputAssetsGrouped(): IAssetsProvider {
|
||||
const inner = useFlatOutputAssets()
|
||||
|
||||
const media = computed(() => groupByJobId(inner.media.value))
|
||||
|
||||
return {
|
||||
...inner,
|
||||
media
|
||||
}
|
||||
}
|
||||
|
||||
function groupByJobId(assets: AssetItem[]): AssetItem[] {
|
||||
const countsByJobId = new Map<string, number>()
|
||||
for (const asset of assets) {
|
||||
const jobId = asset.job_id ?? asset.prompt_id
|
||||
if (!jobId) continue
|
||||
countsByJobId.set(jobId, (countsByJobId.get(jobId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const seenJobIds = new Set<string>()
|
||||
const grouped: AssetItem[] = []
|
||||
for (const asset of assets) {
|
||||
const jobId = asset.job_id ?? asset.prompt_id ?? null
|
||||
if (!jobId) {
|
||||
grouped.push(asset)
|
||||
continue
|
||||
}
|
||||
if (seenJobIds.has(jobId)) continue
|
||||
seenJobIds.add(jobId)
|
||||
|
||||
const outputCount = countsByJobId.get(jobId) ?? 1
|
||||
grouped.push({
|
||||
...asset,
|
||||
user_metadata: {
|
||||
...asset.user_metadata,
|
||||
jobId,
|
||||
outputCount
|
||||
}
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
|
||||
|
||||
function getStackJobId(asset: AssetItem): string | null {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
return metadata?.jobId ?? asset.job_id ?? null
|
||||
return metadata?.jobId ?? null
|
||||
}
|
||||
|
||||
function isStackExpanded(asset: AssetItem): boolean {
|
||||
|
||||
@@ -91,6 +91,16 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL_Advanced', 'model_name'],
|
||||
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
|
||||
|
||||
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
|
||||
@@ -148,6 +158,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
],
|
||||
['checkpoints', 'DynamiCrafterModelLoader', 'ckpt_name'],
|
||||
['controlnet', 'DynamiCrafterCNLoader', 'ckpt_name'],
|
||||
|
||||
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
|
||||
['BEN', 'LS_LoadBenModel', 'model'],
|
||||
@@ -157,6 +169,8 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
|
||||
// ---- Inpaint (comfyui-inpaint-nodes) ----
|
||||
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
|
||||
['inpaint', 'INPAINT_LoadFooocusInpaint', 'head'],
|
||||
['inpaint', 'INPAINT_LoadFooocusInpaint', 'patch'],
|
||||
|
||||
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
|
||||
['layer_model', 'LayeredDiffusionApply', 'config'],
|
||||
@@ -212,9 +226,15 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- Mel-Band RoFormer audio separation (ComfyUI-MelBandRoFormer) ----
|
||||
['diffusion_models', 'MelBandRoFormerModelLoader', 'model_name'],
|
||||
|
||||
// ---- ComfyUI core geometry estimation (MoGe) ----
|
||||
['geometry_estimation', 'LoadMoGeModel', 'model_name'],
|
||||
|
||||
// ---- ComfyUI core optical flow (RAFT) ----
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name']
|
||||
['optical_flow', 'OpticalFlowLoader', 'model_name'],
|
||||
|
||||
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
|
||||
['loras', 'WanVideoLoraSelect', 'lora']
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Metadata for output assets. Originates from the queue/history mapping but
|
||||
* also surfaces on assets sourced directly from `/api/assets?include_tags=output`,
|
||||
* which carry `jobId` only (no per-output `nodeId` / `subfolder`).
|
||||
* Metadata for output assets from queue store
|
||||
* Extends Record<string, unknown> for compatibility with AssetItem schema
|
||||
*/
|
||||
export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
jobId: string
|
||||
nodeId?: string | number
|
||||
subfolder?: string
|
||||
nodeId: string | number
|
||||
subfolder: string
|
||||
executionTimeInSeconds?: number
|
||||
format?: string
|
||||
workflow?: ComfyWorkflowJSON
|
||||
@@ -17,11 +16,17 @@ export interface OutputAssetMetadata extends Record<string, unknown> {
|
||||
allOutputs?: ResultItemImpl[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if metadata is OutputAssetMetadata
|
||||
*/
|
||||
function isOutputAssetMetadata(
|
||||
metadata: Record<string, unknown> | undefined
|
||||
): metadata is OutputAssetMetadata {
|
||||
if (!metadata) return false
|
||||
return typeof metadata.jobId === 'string'
|
||||
return (
|
||||
typeof metadata.jobId === 'string' &&
|
||||
(typeof metadata.nodeId === 'string' || typeof metadata.nodeId === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,10 +18,7 @@ const zAsset = z.object({
|
||||
is_immutable: z.boolean().optional(),
|
||||
last_access_time: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
job_id: z.string().nullish(),
|
||||
// Deprecated alias of job_id. See ingest-types Asset schema; both backends emit this during the L6 transition.
|
||||
prompt_id: z.string().nullish()
|
||||
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
|
||||
})
|
||||
|
||||
const zAssetResponse = zListAssetsResponse
|
||||
|
||||
@@ -7,31 +7,32 @@ import {
|
||||
import type { NodeValidationError } from './types'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: inputName ?? '',
|
||||
extra_info: inputName
|
||||
function nodeValidationError(
|
||||
type: string,
|
||||
inputName?: string,
|
||||
details = inputName ?? '',
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): NodeValidationError {
|
||||
const extra_info =
|
||||
inputName || Object.keys(extraInfo).length > 0
|
||||
? {
|
||||
input_name: inputName
|
||||
...(inputName ? { input_name: inputName } : {}),
|
||||
...extraInfo
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type,
|
||||
message: 'Validation failed',
|
||||
details,
|
||||
extra_info
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeError() {
|
||||
function requiredInputMissing(inputName?: string): NodeValidationError {
|
||||
return {
|
||||
prompt_id: 'test',
|
||||
timestamp: Date.now(),
|
||||
node_id: 1,
|
||||
node_type: 'KSampler',
|
||||
executed: [],
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA out of memory',
|
||||
traceback: [],
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
...nodeValidationError('required_input_missing', inputName),
|
||||
message: 'Required input is missing'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('interpolates fallback templates when catalog keys are missing in the active locale', () => {
|
||||
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
|
||||
const originalLocale = i18n.global.locale.value
|
||||
const originalKoMessages = i18n.global.getLocaleMessage('ko')
|
||||
|
||||
@@ -83,9 +84,12 @@ describe('errorMessageResolver', () => {
|
||||
nodeDisplayName: '0'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: '0 is missing a required input: seed',
|
||||
displayTitle: 'Required input is missing',
|
||||
displayMessage: 'Required input is missing',
|
||||
displayDetails: 'seed',
|
||||
displayItemLabel: '0 - seed',
|
||||
toastMessage: '0 is missing a required input: seed'
|
||||
toastTitle: 'Required input is missing',
|
||||
toastMessage: 'Required input is missing'
|
||||
})
|
||||
} finally {
|
||||
i18n.global.setLocaleMessage('ko', originalKoMessages)
|
||||
@@ -93,34 +97,348 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves runtime errors with item labels and toast copy', () => {
|
||||
it.for([
|
||||
{
|
||||
type: 'bad_linked_input',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'bad_linked_input',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage: 'A node connection could not be read correctly.',
|
||||
displayDetails: 'KSampler has an invalid connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an invalid connection for model.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
inputName: 'scheduler',
|
||||
expected: {
|
||||
catalogId: 'value_not_in_list',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'Some input values are not available for this node.',
|
||||
displayDetails: 'KSampler has an unsupported value for scheduler.',
|
||||
displayItemLabel: 'KSampler - scheduler',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'KSampler has an unsupported value for scheduler.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value_smaller_than_min',
|
||||
inputName: 'steps',
|
||||
expected: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
displayTitle: 'Input out of range',
|
||||
displayMessage: 'Some input values are outside the allowed range.',
|
||||
displayDetails: 'KSampler has a value below the minimum for steps.',
|
||||
displayItemLabel: 'KSampler - steps',
|
||||
toastTitle: 'Input out of range',
|
||||
toastMessage: 'KSampler has a value below the minimum for steps.'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'return_type_mismatch',
|
||||
inputName: 'model',
|
||||
expected: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
displayTitle: 'Invalid connection',
|
||||
displayMessage:
|
||||
'Connected nodes are using incompatible input and output types.',
|
||||
displayDetails: 'KSampler has an incompatible connection for model.',
|
||||
displayItemLabel: 'KSampler - model',
|
||||
toastTitle: 'Invalid connection',
|
||||
toastMessage: 'KSampler has an incompatible connection for model.'
|
||||
}
|
||||
}
|
||||
])('resolves $type validation errors', ({ type, inputName, expected }) => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: true,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(type, inputName),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'execution_failed',
|
||||
displayItemLabel: 'KSampler',
|
||||
toastTitle: 'KSampler failed',
|
||||
).toEqual(expected)
|
||||
})
|
||||
|
||||
it('includes received values in validation range and option details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'return_type_mismatch',
|
||||
'images',
|
||||
'images, received_type(LATENT) mismatch input_type(IMAGE)',
|
||||
{
|
||||
input_config: ['IMAGE', {}],
|
||||
received_type: 'LATENT'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT.",
|
||||
toastMessage:
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
"Preview Image's images input expects IMAGE, but the connected output is LATENT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, abc, invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: 'abc'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT.",
|
||||
toastMessage:
|
||||
"The value abc for KSampler's steps couldn't be converted to INT."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_smaller_than_min', 'steps', 'steps', {
|
||||
input_config: ['INT', { min: 1 }],
|
||||
received_value: 0
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 0 for KSampler's steps is below the minimum 1.",
|
||||
toastMessage: "The value 0 for KSampler's steps is below the minimum 1."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError('value_bigger_than_max', 'cfg', 'cfg', {
|
||||
input_config: ['FLOAT', { max: 30 }],
|
||||
received_value: 40
|
||||
}),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value 40 for KSampler's cfg is above the maximum 30.",
|
||||
toastMessage: "The value 40 for KSampler's cfg is above the maximum 30."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'value_not_in_list',
|
||||
'scheduler',
|
||||
'scheduler',
|
||||
{
|
||||
received_value: 'not-a-scheduler'
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available.",
|
||||
toastMessage:
|
||||
"The value not-a-scheduler for KSampler's scheduler is not available."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves local runtime errors without cloud credit copy', () => {
|
||||
it('falls back to generic copy when structured values cannot be formatted', () => {
|
||||
const circularValue: Record<string, unknown> = {}
|
||||
circularValue.self = circularValue
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
isCloud: false,
|
||||
nodeDisplayName: 'KSampler',
|
||||
error: runtimeError()
|
||||
}).toastMessage
|
||||
).toBe(
|
||||
'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
)
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'invalid_input_type',
|
||||
'steps',
|
||||
"steps, [object Object], invalid literal for int() with base 10: 'abc'",
|
||||
{
|
||||
input_config: ['INT', {}],
|
||||
received_value: circularValue
|
||||
}
|
||||
),
|
||||
nodeDisplayName: 'KSampler'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: "KSampler couldn't convert steps to the expected type.",
|
||||
toastMessage: "KSampler couldn't convert steps to the expected type."
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details when validation itself fails unexpectedly', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_inner_validation',
|
||||
'images',
|
||||
'list index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage: "The workflow couldn't validate a connected node.",
|
||||
displayDetails:
|
||||
"Image Scale couldn't validate images: list index out of range",
|
||||
displayItemLabel: 'Image Scale - images',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
"Image Scale couldn't validate images: list index out of range"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
'tuple index out of range'
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Validation failed',
|
||||
displayMessage:
|
||||
'The workflow could not be validated because a node validation check failed unexpectedly.',
|
||||
displayDetails:
|
||||
'Preview Image failed during validation: tuple index out of range',
|
||||
displayItemLabel: 'Preview Image',
|
||||
toastTitle: 'Validation failed',
|
||||
toastMessage:
|
||||
'Preview Image failed during validation: tuple index out of range'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'exception_during_validation',
|
||||
undefined,
|
||||
''
|
||||
),
|
||||
nodeDisplayName: 'Preview Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayDetails: 'Preview Image failed during validation.',
|
||||
toastMessage: 'Preview Image failed during validation.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves custom validation image failures as image-not-loaded copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
'image - Invalid image file: broken.png'
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayDetails:
|
||||
"The image for Load Image couldn't be loaded. Try adding it again.",
|
||||
displayItemLabel: 'Load Image',
|
||||
toastTitle: "Input image couldn't be loaded"
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'image',
|
||||
"[Errno 21] Is a directory: '/app/comfyui/input'"
|
||||
),
|
||||
nodeDisplayName: 'Load Image'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image.",
|
||||
displayItemLabel: 'Load Image'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw details for generic custom validation failures', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
'setting',
|
||||
'setting - Unsupported lab value: bad-value'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
catalogId: 'custom_validation_failed',
|
||||
displayTitle: 'Invalid input',
|
||||
displayMessage: 'A node rejected one or more input values.',
|
||||
displayDetails:
|
||||
'Custom Validation Error failed custom validation: setting - Unsupported lab value: bad-value',
|
||||
displayItemLabel: 'Custom Validation Error - setting',
|
||||
toastTitle: 'Invalid input',
|
||||
toastMessage: 'Custom Validation Error rejected the value for setting.'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat raw details as the input name when input metadata is missing', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'custom_validation_failed',
|
||||
undefined,
|
||||
'Traceback line 1\nTraceback line 2'
|
||||
),
|
||||
nodeDisplayName: 'Custom Validation Error'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayItemLabel: 'Custom Validation Error - unknown input',
|
||||
toastMessage:
|
||||
'Custom Validation Error rejected the value for unknown input.'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes raw cycle paths for dependency cycle details', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'node_validation',
|
||||
error: nodeValidationError(
|
||||
'dependency_cycle',
|
||||
undefined,
|
||||
'7 (ImageScale) -> 7 (ImageScale)'
|
||||
),
|
||||
nodeDisplayName: 'Image Scale'
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Invalid workflow',
|
||||
displayMessage: 'The workflow has a circular node connection.',
|
||||
displayDetails:
|
||||
'Image Scale is part of a circular connection: 7 (ImageScale) to 7 (ImageScale)',
|
||||
displayItemLabel: 'Image Scale',
|
||||
toastTitle: 'Invalid workflow',
|
||||
toastMessage: 'Image Scale is part of a circular connection.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves known prompt errors with run error rules', () => {
|
||||
@@ -135,6 +453,7 @@ describe('errorMessageResolver', () => {
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt has no outputs',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
})
|
||||
@@ -182,6 +501,91 @@ describe('errorMessageResolver', () => {
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves newly cataloged prompt-level errors', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'missing_node_type',
|
||||
message:
|
||||
"Node 'ID #4' has no class_type. The workflow may be corrupted or a custom node is missing.",
|
||||
details: "Node ID '#4'"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Missing node type',
|
||||
displayMessage:
|
||||
'A node type is missing or unavailable. The workflow may be corrupted or require a custom node.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again. No credits charged.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: false,
|
||||
error: {
|
||||
type: 'OOMError',
|
||||
message: 'OOMError: Workflow execution failed',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'out_of_memory',
|
||||
displayTitle: 'Generation failed',
|
||||
displayMessage:
|
||||
'Not enough GPU memory. Try reducing complexity and run again.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'ImageDownloadError',
|
||||
message: 'ImageDownloadError: Failed to validate images',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'image_not_loaded',
|
||||
displayTitle: 'Image not loaded',
|
||||
displayMessage: "The system couldn't load this image."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Prompt outputs failed validation',
|
||||
details: ''
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'Prompt validation failed',
|
||||
displayMessage:
|
||||
'The workflow has invalid node inputs. Fix the highlighted nodes before running it again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves missing error group display copy', () => {
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
|
||||
@@ -9,22 +9,33 @@ import { st, t, te } from '@/i18n'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
|
||||
const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
|
||||
const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
|
||||
const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'prompt_no_outputs',
|
||||
'no_prompt',
|
||||
'server_error'
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation'
|
||||
])
|
||||
|
||||
interface ValidationCatalogRule {
|
||||
catalogId: string
|
||||
itemLabel: 'node' | 'nodeInput'
|
||||
copyKeys?: CopyKeys
|
||||
}
|
||||
|
||||
interface ErrorResolveContext {
|
||||
isCloud?: boolean
|
||||
nodeDisplayName?: string
|
||||
}
|
||||
|
||||
type CatalogParams = Record<string, string | number>
|
||||
|
||||
function translateCatalogMessage(
|
||||
key: string,
|
||||
fallback: string,
|
||||
params?: Record<string, string | number>
|
||||
params?: CatalogParams
|
||||
): string {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
if (!params) return fallback
|
||||
@@ -34,6 +45,15 @@ function translateCatalogMessage(
|
||||
)
|
||||
}
|
||||
|
||||
function translateOptionalCatalogMessage(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
params?: CatalogParams
|
||||
): string | undefined {
|
||||
if (te(key)) return params ? t(key, params) : t(key)
|
||||
return fallback?.trim() ? fallback : undefined
|
||||
}
|
||||
|
||||
function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
return (
|
||||
nodeDisplayName?.trim() ||
|
||||
@@ -42,99 +62,362 @@ function normalizeNodeName(nodeDisplayName: string | undefined): string {
|
||||
}
|
||||
|
||||
function getInputName(error: NodeValidationError): string {
|
||||
const inputName = error.extra_info?.input_name ?? error.details
|
||||
const inputName = error.extra_info?.input_name
|
||||
return (
|
||||
inputName?.trim() ||
|
||||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
|
||||
)
|
||||
}
|
||||
|
||||
function isRequiredInputMissing(
|
||||
error: NodeValidationError
|
||||
): error is NodeValidationError & { type: typeof REQUIRED_INPUT_MISSING_TYPE } {
|
||||
return error.type === REQUIRED_INPUT_MISSING_TYPE
|
||||
function getErrorText(error: NodeValidationError) {
|
||||
return [
|
||||
'message' in error ? error.message : undefined,
|
||||
'details' in error ? error.details : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isImageNotLoadedText(text: string): boolean {
|
||||
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
|
||||
}
|
||||
|
||||
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
isImageNotLoadedText(getErrorText(error))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeInputItemLabel(nodeName: string, inputName: string): string {
|
||||
return `${nodeName} - ${inputName}`
|
||||
}
|
||||
|
||||
function formatDependencyCycleDetails(details: string): string {
|
||||
// Core reports dependency cycle paths as "node -> node"; catalog copy embeds
|
||||
// those paths in prose, where "to" reads more naturally.
|
||||
return details.replace(/\s*->\s*/g, ' to ')
|
||||
}
|
||||
|
||||
function formatCatalogValue(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getInputConfigValue(
|
||||
error: NodeValidationError,
|
||||
key: 'min' | 'max'
|
||||
): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
const config = inputConfig[1]
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
|
||||
return formatCatalogValue((config as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
return formatCatalogValue(inputConfig[0])
|
||||
}
|
||||
|
||||
function getValidationParams(
|
||||
error: NodeValidationError,
|
||||
nodeName: string,
|
||||
inputName: string
|
||||
): CatalogParams {
|
||||
const params: CatalogParams = { nodeName, inputName }
|
||||
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
|
||||
const receivedType = formatCatalogValue(error.extra_info?.received_type)
|
||||
const expectedType = getInputConfigType(error)
|
||||
const minValue = getInputConfigValue(error, 'min')
|
||||
const maxValue = getInputConfigValue(error, 'max')
|
||||
|
||||
if (receivedValue !== undefined) params.receivedValue = receivedValue
|
||||
if (receivedType !== undefined) params.receivedType = receivedType
|
||||
if (expectedType !== undefined) params.expectedType = expectedType
|
||||
if (minValue !== undefined) params.minValue = minValue
|
||||
if (maxValue !== undefined) params.maxValue = maxValue
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function hasParams(params: CatalogParams, keys: string[]): boolean {
|
||||
return keys.every((key) => params[key] !== undefined)
|
||||
}
|
||||
|
||||
interface CopyKeys {
|
||||
detailsKey: string
|
||||
toastMessageKey: string
|
||||
}
|
||||
|
||||
const DEFAULT_COPY_KEYS: CopyKeys = {
|
||||
detailsKey: 'details',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
|
||||
const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
string,
|
||||
{
|
||||
requiredParams: string[]
|
||||
suffix: 'WithTypes' | 'WithValue'
|
||||
}
|
||||
> = {
|
||||
return_type_mismatch: {
|
||||
requiredParams: ['expectedType', 'receivedType'],
|
||||
suffix: 'WithTypes'
|
||||
},
|
||||
invalid_input_type: {
|
||||
requiredParams: ['receivedValue', 'expectedType'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
requiredParams: ['receivedValue', 'minValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
requiredParams: ['receivedValue', 'maxValue'],
|
||||
suffix: 'WithValue'
|
||||
},
|
||||
value_not_in_list: {
|
||||
requiredParams: ['receivedValue'],
|
||||
suffix: 'WithValue'
|
||||
}
|
||||
}
|
||||
|
||||
function getValueSpecificCopyKeys(
|
||||
errorType: string,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
|
||||
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: `details${rule.suffix}`,
|
||||
toastMessageKey: `toastMessage${rule.suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
return error.details.trim()
|
||||
? {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessageWithRawDetails'
|
||||
}
|
||||
: DEFAULT_COPY_KEYS
|
||||
}
|
||||
|
||||
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
|
||||
if (!error.details.trim()) return DEFAULT_COPY_KEYS
|
||||
|
||||
return {
|
||||
detailsKey: 'detailsWithRawDetails',
|
||||
toastMessageKey: 'toastMessage'
|
||||
}
|
||||
}
|
||||
|
||||
function getValidationCopyKeys(
|
||||
error: NodeValidationError,
|
||||
params: CatalogParams
|
||||
): CopyKeys {
|
||||
if (error.type === 'exception_during_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'exception_during_inner_validation') {
|
||||
return getRawDetailsCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'custom_validation_failed') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
if (error.type === 'dependency_cycle') {
|
||||
return getRawDetailsOnlyCopyKeys(error)
|
||||
}
|
||||
|
||||
return getValueSpecificCopyKeys(error.type, params)
|
||||
}
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
bad_linked_input: {
|
||||
catalogId: 'bad_linked_input',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
return_type_mismatch: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
invalid_input_type: {
|
||||
catalogId: 'invalid_input_type',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
catalogId: 'value_bigger_than_max',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_not_in_list: {
|
||||
catalogId: 'value_not_in_list',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
custom_validation_failed: {
|
||||
catalogId: 'custom_validation_failed',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_inner_validation: {
|
||||
catalogId: 'exception_during_inner_validation',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_validation: {
|
||||
catalogId: 'exception_during_validation',
|
||||
itemLabel: 'node'
|
||||
},
|
||||
dependency_cycle: {
|
||||
catalogId: 'dependency_cycle',
|
||||
itemLabel: 'node'
|
||||
}
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so it needs a
|
||||
// predicate override to use image_not_loaded locale copy and default copy keys.
|
||||
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
itemLabel: 'node',
|
||||
copyKeys: DEFAULT_COPY_KEYS
|
||||
} satisfies ValidationCatalogRule
|
||||
|
||||
function resolveValidationCatalogCopy(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext,
|
||||
localeKey: string,
|
||||
rule: ValidationCatalogRule
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const trimmedDetails = error.details.trim()
|
||||
const rawDetails =
|
||||
error.type === 'dependency_cycle'
|
||||
? formatDependencyCycleDetails(trimmedDetails)
|
||||
: trimmedDetails
|
||||
const params = {
|
||||
...getValidationParams(error, nodeName, inputName),
|
||||
rawDetails
|
||||
}
|
||||
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
|
||||
const titleFallback = error.message || error.type
|
||||
const itemLabelFallback =
|
||||
rule.itemLabel === 'node'
|
||||
? nodeName
|
||||
: nodeInputItemLabel(nodeName, inputName)
|
||||
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
|
||||
|
||||
return {
|
||||
catalogId: rule.catalogId,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
error.message,
|
||||
params
|
||||
),
|
||||
displayDetails: translateOptionalCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.detailsKey}`,
|
||||
error.details,
|
||||
params
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
itemLabelFallback,
|
||||
params
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
titleFallback,
|
||||
params
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.${copyKeys.toastMessageKey}`,
|
||||
error.message,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNodeValidationErrorMessage(
|
||||
error: NodeValidationError,
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (!isRequiredInputMissing(error)) return {}
|
||||
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const keyPrefix = 'errorCatalog.validationErrors.required_input_missing'
|
||||
|
||||
return {
|
||||
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
|
||||
displayTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.title`,
|
||||
'Missing connection'
|
||||
),
|
||||
displayMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.message`,
|
||||
'Required input slots have no connection feeding them.'
|
||||
),
|
||||
displayDetails: translateCatalogMessage(
|
||||
`${keyPrefix}.details`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
'{nodeName} - {inputName}',
|
||||
{ nodeName, inputName }
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'Required input missing'
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
`${keyPrefix}.toastMessage`,
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
{ nodeName, inputName }
|
||||
if (isImageNotLoadedValidationError(error)) {
|
||||
return resolveValidationCatalogCopy(
|
||||
error,
|
||||
context,
|
||||
'image_not_loaded',
|
||||
IMAGE_NOT_LOADED_VALIDATION_RULE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutionErrorMessage(
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const keyPrefix = 'errorCatalog.runtimeErrors.execution_failed'
|
||||
const toastMessageKey = context.isCloud
|
||||
? `${keyPrefix}.toastMessageCloud`
|
||||
: `${keyPrefix}.toastMessageLocal`
|
||||
const toastMessageFallback = context.isCloud
|
||||
? 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
|
||||
: 'This node threw an error during execution. Check its inputs or try a different configuration.'
|
||||
const rule = VALIDATION_ERROR_RULES[error.type]
|
||||
if (!rule) return {}
|
||||
|
||||
return {
|
||||
catalogId: EXECUTION_FAILED_CATALOG_ID,
|
||||
displayItemLabel: translateCatalogMessage(
|
||||
`${keyPrefix}.itemLabel`,
|
||||
nodeName,
|
||||
{
|
||||
nodeName
|
||||
}
|
||||
),
|
||||
toastTitle: translateCatalogMessage(
|
||||
`${keyPrefix}.toastTitle`,
|
||||
'{nodeName} failed',
|
||||
{ nodeName }
|
||||
),
|
||||
toastMessage: translateCatalogMessage(
|
||||
toastMessageKey,
|
||||
toastMessageFallback,
|
||||
{ nodeName }
|
||||
)
|
||||
}
|
||||
return resolveValidationCatalogCopy(error, context, error.type, rule)
|
||||
}
|
||||
|
||||
function resolvePromptErrorMessage(
|
||||
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
|
||||
context: ErrorResolveContext
|
||||
): ResolvedErrorMessage {
|
||||
if (error.type === 'ImageDownloadError') {
|
||||
return {
|
||||
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorCatalog.promptErrors.image_not_loaded.desc',
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (error.type === 'OOMError') {
|
||||
const messageKey = context.isCloud
|
||||
? 'errorCatalog.promptErrors.out_of_memory.descCloud'
|
||||
: 'errorCatalog.promptErrors.out_of_memory.descLocal'
|
||||
|
||||
return {
|
||||
catalogId: OUT_OF_MEMORY_CATALOG_ID,
|
||||
displayTitle: st(
|
||||
'errorCatalog.promptErrors.out_of_memory.title',
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(messageKey, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
|
||||
|
||||
const errorTypeKey =
|
||||
@@ -145,6 +428,10 @@ function resolvePromptErrorMessage(
|
||||
: error.type
|
||||
|
||||
return {
|
||||
displayTitle: translateCatalogMessage(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.title`,
|
||||
error.message
|
||||
),
|
||||
displayMessage: st(
|
||||
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
|
||||
error.message
|
||||
@@ -231,11 +518,6 @@ export function resolveRunErrorMessage(
|
||||
return resolveNodeValidationErrorMessage(source.error, {
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'execution':
|
||||
return resolveExecutionErrorMessage({
|
||||
isCloud: source.isCloud,
|
||||
nodeDisplayName: source.nodeDisplayName
|
||||
})
|
||||
case 'prompt':
|
||||
return resolvePromptErrorMessage(source.error, {
|
||||
isCloud: source.isCloud
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeError, PromptError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
@@ -39,12 +35,6 @@ export type RunErrorMessageSource =
|
||||
error: NodeValidationError
|
||||
nodeDisplayName: string
|
||||
}
|
||||
| {
|
||||
kind: 'execution'
|
||||
error: ExecutionErrorWsMessage
|
||||
nodeDisplayName?: string
|
||||
isCloud: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'prompt'
|
||||
error: PromptError
|
||||
|
||||
@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey when no nodeId is available at all', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses duplicate normal widgets on the same node to one render', () => {
|
||||
const colorA = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const colorB = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'ColorToRGBInt',
|
||||
widgets: [colorA, colorB],
|
||||
title: 'Color to RGB Int',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
@@ -129,11 +129,15 @@ export function getWidgetIdentity(
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IColorWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
const widgets: IColorWidget[] = []
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
options: IWidgetOptions
|
||||
) => {
|
||||
const widget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
callback: _callback
|
||||
} as unknown as IColorWidget
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return { widgets, addWidget } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const colorSpec: InputSpec = {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
default: '#ffffff',
|
||||
socketless: true
|
||||
}
|
||||
|
||||
describe('useColorWidget', () => {
|
||||
it('reads the top-level default from the V2 spec', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, colorSpec)
|
||||
expect(widget.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('falls back to nested options.default when top-level default is absent', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
options: { default: '#abcdef' }
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#abcdef')
|
||||
})
|
||||
|
||||
it('falls back to #000000 when no default is declared', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color'
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns the existing widget instead of creating a duplicate', () => {
|
||||
const node = createMockNode()
|
||||
const first = useColorWidget()(node, colorSpec)
|
||||
const second = useColorWidget()(node, colorSpec)
|
||||
expect(second).toBe(first)
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
const colorSpec = inputSpec as ColorInputSpec
|
||||
const { name, options } = colorSpec
|
||||
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
|
||||
|
||||
const existing = node.widgets?.find(
|
||||
(w): w is IColorWidget => w.name === name && w.type === 'color'
|
||||
)
|
||||
if (existing) return existing
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
|
||||
@@ -473,6 +473,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
node.imgs = [element]
|
||||
node.imageIndex = activeIndex
|
||||
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (outputs?.images) node.images = outputs.images
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user