mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-16 19:39:28 +00:00
Compare commits
2 Commits
main
...
cloud/1.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af771a45d0 | ||
|
|
cb4c3b833b |
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 321 KiB |
@@ -1,103 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
interface UploadResponse {
|
||||
name: string
|
||||
subfolder: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
}
|
||||
|
||||
const IMAGE_CANVAS_INDEX = 0
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
|
||||
const successResponse = (name: string): UploadResponse => ({
|
||||
name,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
const fulfillJson = (body: UploadResponse) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
test('Save with drawn mask uploads non-empty mask data', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
expect(observedBodyLength).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const imageDimensions =
|
||||
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
|
||||
const maskDimensions =
|
||||
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
|
||||
|
||||
expect(imageDimensions).not.toBeNull()
|
||||
expect(maskDimensions).not.toBeNull()
|
||||
expect(imageDimensions?.totalPixels).toBe(64 * 64)
|
||||
expect(maskDimensions?.totalPixels).toBe(64 * 64)
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
@@ -8,7 +8,6 @@ import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCountByName
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
@@ -140,46 +139,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'Displays previews inside subgraphs received while workflow inactive',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
const previewImage = new VueNodeFixture(previewLocator)
|
||||
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
const subgraphNode = new VueNodeFixture(subgraphLocator)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
await expect(previewImage.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await previewImage.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(subgraphNode.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Inject Previews from different tab', async () => {
|
||||
const jobId = await execution.run()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await comfyPage.vueNodes.waitForNodes(7)
|
||||
|
||||
const images = [{ filename: 'example.png', type: 'input' }]
|
||||
execution.executed(jobId, '2:1', { images })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function countColumns(locator: Locator) {
|
||||
|
||||
@@ -98,43 +98,4 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
|
||||
})
|
||||
|
||||
test('opens the switcher to the left of the profile menu without overlap', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
const panel = page.getByTestId('workspace-switcher-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
const profileMenu = page.locator('.current-user-popover')
|
||||
const panelBox = await panel.boundingBox()
|
||||
const profileBox = await profileMenu.boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(profileBox).not.toBeNull()
|
||||
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(profileBox!.x)
|
||||
})
|
||||
|
||||
test('opens the create-workspace dialog with DES-246 copy', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
await page.getByTestId('workspace-switcher-trigger').click()
|
||||
|
||||
await page.getByText('Create a workspace').click()
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.'
|
||||
)
|
||||
).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Ex: Comfy Org')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.46.14",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -206,7 +206,7 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25 <26",
|
||||
"node": ">=25",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
|
||||
@@ -7,10 +7,7 @@ export type { ClassValue } from 'clsx'
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': ['text-xxs', 'text-xxxs'],
|
||||
// tailwind-merge does not know Tailwind's `max-h-none`, so it never
|
||||
// resolves conflicts like `max-h-[80vh] max-h-none` (both survive).
|
||||
'max-h': [{ 'max-h': ['none'] }]
|
||||
'font-size': ['text-xxs', 'text-xxxs']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,9 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close', maximizeDialog: 'Maximize' } }
|
||||
},
|
||||
messages: { en: { g: { close: 'Close' } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -195,68 +193,6 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
|
||||
|
||||
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies headerClass and bodyClass on the non-headless path', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-section-classes',
|
||||
title: 'Section classes',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'p-0'
|
||||
}
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const header = screen.getByText('Section classes').parentElement
|
||||
expect(header?.classList.contains('p-2')).toBe(true)
|
||||
// twMerge drops the default header padding in favor of headerClass
|
||||
expect(header?.classList.contains('px-4')).toBe(false)
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const body = screen.getByTestId('body').parentElement
|
||||
expect(body?.classList.contains('p-0')).toBe(true)
|
||||
expect(body?.classList.contains('px-4')).toBe(false)
|
||||
})
|
||||
|
||||
it('maximize overrides custom dimension classes from contentClass', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
const user = userEvent.setup()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reka-maximize-wins',
|
||||
title: 'Maximize wins',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
maximizable: true,
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]'
|
||||
}
|
||||
})
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Maximize' }))
|
||||
|
||||
// Maximized dimensions win over the caller's fixed dimensions,
|
||||
// mirroring PrimeVue's `.p-dialog-maximized` !important behavior.
|
||||
expect(dialog.classList.contains('size-auto')).toBe(true)
|
||||
expect(dialog.classList.contains('max-h-none')).toBe(true)
|
||||
expect(dialog.classList.contains('w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-h-[80vh]')).toBe(false)
|
||||
expect(dialog.classList.contains('max-w-[80vw]')).toBe(false)
|
||||
expect(dialog.classList.contains('sm:max-w-[80vw]')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
@@ -302,22 +238,6 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
|
||||
// A backgrounded dialog must never dismiss on an outside pointer — the
|
||||
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding
|
||||
// opening over Settings). Target is outside any overlay, so only the
|
||||
// is-active gate can prevent it.
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, false)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('allows the top-most dialog to dismiss on a true outside pointer', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event, true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaPointerDownOutside({ dismissableMask: false }, event)
|
||||
|
||||
@@ -18,19 +18,13 @@
|
||||
:maximized="!!item.dialogComponentProps.maximized"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@open-auto-focus="(e) => onRekaOpenAutoFocus(e, item.key)"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
item.dialogComponentProps.closeOnEscape === false &&
|
||||
e.preventDefault()
|
||||
"
|
||||
@pointer-down-outside="
|
||||
(e) =>
|
||||
onRekaPointerDownOutside(
|
||||
item.dialogComponentProps,
|
||||
e,
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
@@ -43,7 +37,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<DialogHeader :class="item.dialogComponentProps.headerClass">
|
||||
<DialogHeader>
|
||||
<component
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
@@ -64,24 +58,14 @@
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 overflow-auto px-4 py-2',
|
||||
item.dialogComponentProps.bodyClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex-1 overflow-auto px-4 py-2">
|
||||
<component
|
||||
:is="item.component"
|
||||
v-bind="item.contentProps"
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter
|
||||
v-if="item.footerComponent"
|
||||
:class="item.dialogComponentProps.footerClass"
|
||||
>
|
||||
<DialogFooter v-if="item.footerComponent">
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</DialogFooter>
|
||||
</template>
|
||||
@@ -125,8 +109,6 @@
|
||||
<script setup lang="ts">
|
||||
import PrimeDialog from 'primevue/dialog'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
|
||||
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
|
||||
@@ -154,22 +136,6 @@ function onRekaOpenChange(key: string, open: boolean) {
|
||||
if (!open) dialogStore.closeDialog({ key })
|
||||
}
|
||||
|
||||
// Reka's FocusScope focuses the first tabbable element on open (often a header
|
||||
// or footer button). Dialog content that marks an input with `autofocus` (e.g.
|
||||
// the keybinding capture input, the prompt input) relied on PrimeVue honoring
|
||||
// that attribute, so honor it here: focus the autofocus target and cancel
|
||||
// Reka's default auto-focus when one is present.
|
||||
function onRekaOpenAutoFocus(event: Event, key: string) {
|
||||
const content = document.querySelector<HTMLElement>(
|
||||
`[aria-labelledby="${CSS.escape(key)}"]`
|
||||
)
|
||||
const autofocusEl = content?.querySelector<HTMLElement>('[autofocus]')
|
||||
if (autofocusEl) {
|
||||
event.preventDefault()
|
||||
autofocusEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximize(item: DialogInstance) {
|
||||
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
@@ -45,28 +44,23 @@ const mockSubscription = vi.hoisted(() => ({
|
||||
value: null as { endDate: string | null } | null
|
||||
}))
|
||||
|
||||
const mockCancelSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockFetchStatus = vi.hoisted(() => vi.fn())
|
||||
const mockCloseDialog = vi.hoisted(() => vi.fn())
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
cancelSubscription: mockCancelSubscription,
|
||||
fetchStatus: mockFetchStatus,
|
||||
cancelSubscription: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
subscription: mockSubscription
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: mockCloseDialog
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -92,54 +86,6 @@ function renderComponent(props: { cancelAt?: string } = {}) {
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cancel flow', () => {
|
||||
it('shows an error toast and keeps the dialog open when cancellation fails', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockRejectedValueOnce(
|
||||
new Error('Subscription cancellation timed out')
|
||||
)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Subscription cancellation timed out'
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the dialog and shows a success toast when cancellation succeeds', async () => {
|
||||
mockSubscription.value = null
|
||||
mockCancelSubscription.mockResolvedValueOnce(undefined)
|
||||
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: /^cancel subscription$/i })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'cancel-subscription'
|
||||
})
|
||||
)
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formattedEndDate fallbacks', () => {
|
||||
it('uses the localized fallback when no cancel timestamp is available', () => {
|
||||
mockSubscription.value = { endDate: null }
|
||||
|
||||
@@ -27,19 +27,8 @@ function isInsideOverlay(target: EventTarget | null): boolean {
|
||||
|
||||
export function onRekaPointerDownOutside(
|
||||
options: { dismissableMask?: boolean },
|
||||
event: OutsideEvent,
|
||||
isActive = true
|
||||
event: OutsideEvent
|
||||
) {
|
||||
// Stacked dialogs each render an independent Reka `Dialog` root, so a lower
|
||||
// dialog's DismissableLayer sees a pointer-down that opened (or landed on)
|
||||
// the dialog above it as "outside" and would dismiss itself — including via
|
||||
// the upper dialog's overlay, whose element matches none of the portal
|
||||
// selectors below. Only the top-most dialog may dismiss on an outside
|
||||
// pointer, mirroring the escape-key handling in `GlobalDialog`.
|
||||
if (!isActive) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
|
||||
@@ -41,9 +41,7 @@ const openIn3DViewer = () => {
|
||||
component: Load3DViewerContent,
|
||||
props: props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true,
|
||||
onClose: async () => {
|
||||
await useLoad3dService().handleViewerClose(props.node)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
|
||||
<div class="flex min-h-8 w-full items-center gap-2 px-3">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<span
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ title }}
|
||||
</span>
|
||||
</button>
|
||||
<slot name="actions" />
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
|
||||
:aria-expanded="!collapse"
|
||||
:aria-controls="bodyId"
|
||||
:aria-label="
|
||||
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
|
||||
"
|
||||
@click="collapse = !collapse"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
|
||||
collapse && '-rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<div v-if="!collapse" :id="bodyId">
|
||||
<slot />
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
|
||||
const {
|
||||
title,
|
||||
count,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const bodyId = useId()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,31 +1,29 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex min-h-8 flex-wrap items-center gap-2"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
@@ -36,7 +34,7 @@
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
@@ -51,7 +49,7 @@
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
@@ -61,29 +59,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
@@ -98,13 +96,13 @@
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
@@ -117,61 +115,60 @@
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-1"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-1 py-1">
|
||||
<span
|
||||
class="text-xs font-semibold text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto">
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-4" />
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-testid="missing-node-card" class="px-3">
|
||||
<div data-testid="missing-node-card" class="px-4 pb-2">
|
||||
<!-- Core node version warning (OSS only) -->
|
||||
<div
|
||||
v-if="!isCloud && hasMissingCoreNodes"
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div class="flex flex-col gap-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-1 overflow-hidden py-2">
|
||||
<MissingPackGroupRow
|
||||
v-for="group in missingPackGroups"
|
||||
:key="group.packId ?? '__unknown__'"
|
||||
@@ -75,7 +75,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="isRestarting"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
|
||||
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
|
||||
@click="applyChanges()"
|
||||
>
|
||||
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
: t('rightSidePanel.missingNodePacks.expand')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
<i
|
||||
@@ -64,7 +64,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal"
|
||||
:class="
|
||||
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
|
||||
"
|
||||
@@ -80,7 +80,7 @@
|
||||
v-if="showInfoButton && group.packId !== null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||
>
|
||||
@@ -89,7 +89,7 @@
|
||||
<span
|
||||
v-if="showNodeCount"
|
||||
data-testid="missing-node-pack-count"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isPackInstalled || isInstalling"
|
||||
@click="handlePackInstallClick"
|
||||
>
|
||||
@@ -122,10 +122,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showLoadingAction"
|
||||
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
|
||||
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
|
||||
>
|
||||
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||
<span class="text-foreground min-w-0 truncate text-xs">
|
||||
<span class="text-foreground min-w-0 truncate text-sm">
|
||||
{{ t('g.loading') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="
|
||||
openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
@@ -150,7 +150,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -163,7 +163,7 @@
|
||||
v-if="showNodeTypeList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-1 p-0',
|
||||
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -190,7 +190,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -199,7 +199,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
@@ -241,7 +241,7 @@ const { t } = useI18n()
|
||||
const expandedOverride = ref<boolean | null>(null)
|
||||
|
||||
const packTextButtonClass =
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
|
||||
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
|
||||
|
||||
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
@@ -78,10 +78,6 @@ describe('TabErrors.vue', () => {
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorsDetected: 'Error detected | Errors detected',
|
||||
resolveBeforeRun: 'Resolve before running the workflow',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
@@ -122,6 +118,9 @@ describe('TabErrors.vue', () => {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('3')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Errors detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
@@ -411,7 +404,7 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('missing-model-actions')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -421,40 +414,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts missing models per file when several share one directory', () => {
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-a.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'model-b.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
}
|
||||
] satisfies MissingModelCandidate[]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-model')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
@@ -472,7 +431,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
@@ -494,7 +453,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
@@ -536,12 +495,6 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-missing-media')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('errors-summary-hero')).getByText('2')
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Second Loader - image' })
|
||||
@@ -573,7 +526,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -11,62 +11,49 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-lg border border-secondary-background"
|
||||
>
|
||||
<!-- Errors summary hero -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
data-testid="errors-summary-hero"
|
||||
class="flex items-center gap-2 bg-base-foreground/5 p-2"
|
||||
v-if="filteredGroups.length === 0"
|
||||
key="empty"
|
||||
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
|
||||
>
|
||||
{{ totalErrorCount }}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-9 w-px shrink-0 bg-interface-stroke"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
|
||||
<span class="text-xs/tight font-semibold text-base-foreground">
|
||||
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
|
||||
</span>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.resolveBeforeRun') }}
|
||||
</span>
|
||||
</div>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Group by Class Type -->
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<ErrorCardSection
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:title="group.displayTitle"
|
||||
:count="getGroupCount(group)"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-t border-secondary-background first:border-t-0"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.groupKey"
|
||||
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
||||
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span class="truncate text-destructive-background-hover">
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="
|
||||
group.type === 'missing_node' &&
|
||||
@@ -75,7 +62,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
:disabled="isInstallingAll"
|
||||
@click.stop="installAll"
|
||||
>
|
||||
@@ -96,7 +83,7 @@
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
@@ -109,7 +96,7 @@
|
||||
data-testid="missing-model-header-refresh"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.missingModels.refresh')"
|
||||
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
||||
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
||||
@@ -142,142 +129,140 @@
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
:show-info-button="shouldShowManagerButtons"
|
||||
:missing-pack-groups="missingPackGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@open-manager-info="handleOpenManagerInfo"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-3">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@replace="handleReplaceGroup"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div
|
||||
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
|
||||
>
|
||||
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
@@ -319,16 +304,15 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorCardSection from './ErrorCardSection.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
@@ -372,6 +356,16 @@ const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
@@ -458,28 +452,6 @@ const {
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery)
|
||||
|
||||
function getGroupCount(group: ErrorGroup): number {
|
||||
switch (group.type) {
|
||||
case 'execution':
|
||||
return getExecutionGroupCount(group)
|
||||
case 'missing_node':
|
||||
return missingPackGroups.value.length
|
||||
case 'swap_nodes':
|
||||
return swapNodeGroups.value.length
|
||||
case 'missing_model':
|
||||
return missingModelGroups.value.reduce(
|
||||
(total, modelGroup) => total + modelGroup.models.length,
|
||||
0
|
||||
)
|
||||
case 'missing_media':
|
||||
return countMissingMediaReferences(missingMediaGroups.value)
|
||||
}
|
||||
}
|
||||
|
||||
const totalErrorCount = computed(() =>
|
||||
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
|
||||
)
|
||||
|
||||
const showMissingModelHeaderRefresh = computed(
|
||||
() => !isCloud && missingModelGroups.value.length > 0
|
||||
)
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(missingGroup).toBeDefined()
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
expect(modelGroup?.groupKey).toBe('missing_model')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models')
|
||||
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
|
||||
const missingMediaGroup = groups.allErrorGroups.value.find(
|
||||
(group) => group.type === 'missing_media'
|
||||
)
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
|
||||
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -586,9 +586,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
modelUrl: asset.preview_url || getAssetUrl(asset)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,9 +189,7 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
modelUrl: previewOutput.url || ''
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,15 +28,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
|
||||
<template>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
dialogContentVariants({ size, maximized }),
|
||||
customClass,
|
||||
// Custom dimension classes must yield to maximize, mirroring the
|
||||
// PrimeVue `.p-dialog-maximized` !important behavior.
|
||||
maximized && 'size-auto max-h-none max-w-none sm:max-w-none'
|
||||
)
|
||||
"
|
||||
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
|
||||
>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
|
||||
@@ -43,18 +43,13 @@ export function useKeyboard() {
|
||||
}
|
||||
|
||||
const addListeners = (): void => {
|
||||
// Capture phase: the Mask Editor content root carries `@keydown.stop`
|
||||
// (MaskEditorContent.vue), so a bubble-phase listener never sees keydowns
|
||||
// that originate inside it. Under the Reka dialog the focus trap keeps
|
||||
// focus on an in-editor input, so Ctrl+Z/Y (undo/redo) and the space-pan
|
||||
// blur were swallowed. Capturing runs this before that stopPropagation.
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
const removeListeners = (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', clearKeys)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,21 @@ export function useMaskEditor() {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// `mask-editor-dialog` is a styling-free hook class consumed by
|
||||
// browser_tests (MaskEditorHelper, maskEditor.spec).
|
||||
contentClass: 'mask-editor-dialog w-[90vw] h-[90vh] max-h-[90vh]',
|
||||
headerClass: 'p-2',
|
||||
bodyClass: 'flex min-h-0 flex-col p-0',
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,94 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCopy } from './useCopy'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const copyMocks = vi.hoisted(() => ({
|
||||
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
|
||||
canvas: {
|
||||
selectedItems: new Set<object>([{}]),
|
||||
copyToClipboard: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn(
|
||||
(
|
||||
_target: EventTarget,
|
||||
event: string,
|
||||
handler: (event: ClipboardEvent) => unknown
|
||||
) => {
|
||||
if (event === 'copy') copyMocks.copyHandler = handler
|
||||
return vi.fn()
|
||||
}
|
||||
/**
|
||||
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
|
||||
*/
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(
|
||||
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: copyMocks.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/eventHelpers', () => ({
|
||||
shouldIgnoreCopyPaste: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const multiChunkPayloadLength = 0x8000 * 6 + 123
|
||||
|
||||
function copySerializedData(serializedData: string): DataTransfer {
|
||||
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
|
||||
|
||||
useCopy()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
const event = new ClipboardEvent('copy', {
|
||||
clipboardData: dataTransfer
|
||||
})
|
||||
const copyHandler = copyMocks.copyHandler
|
||||
expect(copyHandler).toBeDefined()
|
||||
if (!copyHandler) throw new Error('Expected copy handler to be registered')
|
||||
|
||||
expect(() => copyHandler(event)).not.toThrow()
|
||||
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
|
||||
const match = dataTransfer
|
||||
.getData('text/html')
|
||||
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
|
||||
expect(match).toBeDefined()
|
||||
if (!match) throw new Error('Expected clipboard metadata to be written')
|
||||
|
||||
const binaryString = atob(match)
|
||||
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
|
||||
/**
|
||||
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
|
||||
*/
|
||||
function decodeClipboardData(base64: string): string {
|
||||
const binaryString = atob(base64)
|
||||
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
describe('useCopy', () => {
|
||||
beforeEach(() => {
|
||||
copyMocks.copyHandler = undefined
|
||||
copyMocks.canvas.copyToClipboard.mockReset()
|
||||
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
|
||||
it('should handle ASCII-only strings', () => {
|
||||
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should write large serialized node data to clipboard metadata', () => {
|
||||
const serializedData = JSON.stringify({
|
||||
it('should handle Chinese characters in localized_name', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Japanese characters', () => {
|
||||
const original = '{"localized_name":"画像を読み込む"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle Korean characters', () => {
|
||||
const original = '{"localized_name":"이미지 불러오기"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed ASCII and Unicode characters', () => {
|
||||
const original =
|
||||
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle emoji characters', () => {
|
||||
const original = '{"title":"Test Node 🎨🖼️"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const original = ''
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex node data with multiple Unicode fields', () => {
|
||||
const original = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Subgraph',
|
||||
title: 'Large Subgraph',
|
||||
localized_name: '이미지 그룹 图像 🎨',
|
||||
payload: 'x'.repeat(multiChunkPayloadLength)
|
||||
type: 'LoadImage',
|
||||
localized_name: '图像',
|
||||
inputs: [{ localized_name: '图片', name: 'image' }],
|
||||
outputs: [{ localized_name: '输出', name: 'output' }]
|
||||
}
|
||||
],
|
||||
groups: [{ title: '预处理组 🔧' }],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: []
|
||||
links: []
|
||||
})
|
||||
const encoded = encodeClipboardData(original)
|
||||
const decoded = decodeClipboardData(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
|
||||
})
|
||||
|
||||
const dataTransfer = copySerializedData(serializedData)
|
||||
it('should produce valid base64 output', () => {
|
||||
const original = '{"localized_name":"中文测试"}'
|
||||
const encoded = encodeClipboardData(original)
|
||||
// Base64 should only contain valid characters
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
|
||||
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
|
||||
it('should fail with plain btoa for non-Latin1 characters', () => {
|
||||
const original = '{"localized_name":"图像"}'
|
||||
// This demonstrates why we need TextEncoder - plain btoa fails
|
||||
expect(() => btoa(original)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,29 +7,6 @@ const clipboardHTMLWrapper = [
|
||||
'<meta charset="utf-8"><div><span data-metadata="',
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
]
|
||||
const clipboardByteChunkSize = 0x8000
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = []
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < bytes.length;
|
||||
offset += clipboardByteChunkSize
|
||||
) {
|
||||
chunks.push(
|
||||
String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + clipboardByteChunkSize)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
function encodeClipboardData(data: string): string {
|
||||
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
@@ -46,16 +23,17 @@ export const useCopy = () => {
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas?.selectedItems) {
|
||||
const serializedData = canvas.copyToClipboard()
|
||||
try {
|
||||
const base64Data = encodeClipboardData(serializedData)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
// Use TextEncoder to handle Unicode characters properly
|
||||
const base64Data = btoa(
|
||||
String.fromCharCode(
|
||||
...Array.from(new TextEncoder().encode(serializedData))
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
// clearData doesn't remove images from clipboard
|
||||
e.clipboardData?.setData(
|
||||
'text/html',
|
||||
clipboardHTMLWrapper.join(base64Data)
|
||||
)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return false
|
||||
|
||||
@@ -271,10 +271,7 @@ useExtensionService().registerExtension({
|
||||
component: Load3DViewerContent,
|
||||
props: props,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[80vw] max-w-[80vw] sm:max-w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
style: 'width: 80vw; height: 80vh;',
|
||||
maximizable: true,
|
||||
onClose: async () => {
|
||||
await useLoad3dService().handleViewerClose(props.node)
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت الذي سيتم إضافته للفيديو."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "عمق البت للفيديو المُنشأ. عمق ١٠ بت يحافظ على تدرجات أكثر سلاسة مع تقليل التدرج اللوني، لكن بعض المشغلات والعُقد اللاحقة قد لا تدعمه."
|
||||
},
|
||||
"fps": {
|
||||
"name": "الإطارات في الثانية"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "معدل_الإطارات",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "استيراد نموذج ثلاثي الأبعاد خارجي (مثلاً من Rodin، Hunyuan3D أو ملف محلي) إلى Tripo لاستخدامه مع عُقد المعالجة اللاحقة في Tripo: الإكساء، التحريك، التحويل. يُوصى باستخدام GLB: تبقى الخامات محفوظة فقط إذا كانت مضمنة داخل الملف. يرجى ملاحظة أن إكساء نموذج مستورد يتطلب مطالبة إكساء.",
|
||||
"display_name": "Tripo: استيراد نموذج",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "نموذج ثلاثي الأبعاد للاستيراد (GLB / FBX / OBJ / STL، حتى ١٥٠ ميجابايت). ملفات OBJ و STL لا تحتوي على خامات مضمنة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: متعدد المناظر إلى نموذج",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "محاذاة_الملمس"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "إرشادات نصية اختيارية للإكساء. مطلوبة عملياً للنماذج المستوردة (Tripo: استيراد نموذج)، والتي لا تحتوي على صورة مصدر لاستنتاج الألوان منها."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "جودة_الملمس"
|
||||
},
|
||||
|
||||
@@ -2409,9 +2409,7 @@
|
||||
"topupProcessing": "Processing payment — adding credits...",
|
||||
"topupSuccess": "Credits added successfully",
|
||||
"topupFailed": "Top-up failed",
|
||||
"topupTimeout": "Top-up verification timed out",
|
||||
"cancelFailed": "Failed to cancel subscription",
|
||||
"cancelTimeout": "Subscription cancellation timed out"
|
||||
"topupTimeout": "Top-up verification timed out"
|
||||
},
|
||||
"subscription": {
|
||||
"plansForWorkspace": "Plans for {workspace}",
|
||||
@@ -2703,9 +2701,9 @@
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
"message": "Workspaces keep your projects and files organized. Subscribe to a Team plan to invite members.",
|
||||
"nameLabel": "Workspace name",
|
||||
"namePlaceholder": "Ex: Comfy Org",
|
||||
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
|
||||
"nameLabel": "Workspace name*",
|
||||
"namePlaceholder": "Enter workspace name",
|
||||
"create": "Create"
|
||||
},
|
||||
"toast": {
|
||||
@@ -2751,7 +2749,7 @@
|
||||
"personal": "Personal",
|
||||
"roleOwner": "Owner",
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"createWorkspace": "Create new workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
},
|
||||
@@ -3627,10 +3625,6 @@
|
||||
"hideAdvancedShort": "Hide advanced",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"errorsDetected": "Error detected | Errors detected",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
|
||||
@@ -2910,10 +2910,6 @@
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "The audio to add to the video."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Bit depth of the created video. 10-bit keeps smoother gradients with less banding, but some players and downstream nodes may not support it."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -5097,7 +5093,7 @@
|
||||
},
|
||||
"GetVideoComponents": {
|
||||
"display_name": "Get Video Components",
|
||||
"description": "Extracts all components from a video: frames, audio, framerate, and bit depth.",
|
||||
"description": "Extracts all components from a video: frames, audio, and framerate.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video",
|
||||
@@ -5116,10 +5112,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"display_name": "Tripo: Import Model",
|
||||
"description": "Import an external 3D model (e.g. from Rodin, Hunyuan3D or a local file) into Tripo to use it with Tripo's post-processing nodes: Texture, Rig, Convert. GLB is recommended: textures survive import only when embedded in the file. Note that texturing an imported model requires a texture prompt.",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "3D model to import (GLB / FBX / OBJ / STL, up to 150 MB). OBJ and STL files carry no embedded textures."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multiview to Model",
|
||||
"inputs": {
|
||||
@@ -20083,10 +20059,6 @@
|
||||
},
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Optional text guidance for texturing. Required in practice for imported models (Tripo: Import Model), which carry no source image to infer colors from."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "audio",
|
||||
"tooltip": "El audio que se añadirá al video."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profundidad de bits del video creado. 10 bits mantiene gradientes más suaves con menos bandas, pero algunos reproductores y nodos posteriores pueden no soportarlo."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importa un modelo 3D externo (por ejemplo, de Rodin, Hunyuan3D o un archivo local) en Tripo para usarlo con los nodos de postprocesamiento de Tripo: Texture, Rig, Convert. Se recomienda GLB: las texturas solo se conservan si están incrustadas en el archivo. Ten en cuenta que para texturizar un modelo importado se requiere un prompt de textura.",
|
||||
"display_name": "Tripo: Importar modelo",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modelo 3D a importar (GLB / FBX / OBJ / STL, hasta 150 MB). Los archivos OBJ y STL no contienen texturas incrustadas."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multivista a Modelo",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alineación_de_textura"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Guía de texto opcional para texturizar. Es necesaria en la práctica para modelos importados (Tripo: Importar modelo), que no tienen imagen de origen para inferir colores."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "calidad_de_textura"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "صدا",
|
||||
"tooltip": "صدایی که به ویدیو اضافه میشود."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "عمق بیت ویدئوی ایجادشده. ۱۰-بیت گرادیانهای نرمتری با بندینگ کمتر ایجاد میکند، اما ممکن است برخی پخشکنندهها و nodeهای بعدی از آن پشتیبانی نکنند."
|
||||
},
|
||||
"fps": {
|
||||
"name": "فریم بر ثانیه"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "نرخ فریم",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "یک مدل سهبعدی خارجی (مثلاً از Rodin، Hunyuan3D یا یک فایل محلی) را به Tripo وارد کنید تا بتوانید از آن با nodeهای پسپردازش Tripo مانند Texture، Rig و Convert استفاده کنید. فرمت GLB توصیه میشود: با این فرمت، تکسچرها فقط زمانی پس از وارد کردن باقی میمانند که در فایل جاسازی شده باشند. توجه داشته باشید که تکسچر دادن به یک مدل واردشده نیازمند یک texture prompt است.",
|
||||
"display_name": "Tripo: وارد کردن مدل",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "مدل سهبعدی برای وارد کردن (GLB / FBX / OBJ / STL، تا سقف ۱۵۰ مگابایت). فایلهای OBJ و STL فاقد تکسچر جاسازیشده هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: چندنما به مدل",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "تراز بافت"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "راهنمای متنی اختیاری برای تکسچر دادن. در عمل برای مدلهای واردشده (Tripo: وارد کردن مدل) که تصویر مرجعی برای استخراج رنگ ندارند، الزامی است."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "کیفیت بافت"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "audio",
|
||||
"tooltip": "L’audio à ajouter à la vidéo."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profondeur de bits de la vidéo créée. Le 10 bits conserve des dégradés plus doux avec moins de bandes, mais certains lecteurs et nœuds en aval peuvent ne pas le prendre en charge."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "ips",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importez un modèle 3D externe (par exemple depuis Rodin, Hunyuan3D ou un fichier local) dans Tripo pour l'utiliser avec les nœuds de post-traitement de Tripo : Texture, Rig, Convert. GLB est recommandé : les textures ne sont conservées à l'import que si elles sont intégrées dans le fichier. Notez que le texturage d'un modèle importé nécessite une invite de texture.",
|
||||
"display_name": "Tripo : Importer un modèle",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modèle 3D à importer (GLB / FBX / OBJ / STL, jusqu'à 150 Mo). Les fichiers OBJ et STL ne contiennent pas de textures intégrées."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo : Multivue vers Modèle",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alignement_texture"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Guidage textuel optionnel pour le texturage. Requis en pratique pour les modèles importés (Tripo : Importer un modèle), qui ne contiennent pas d'image source pour déduire les couleurs."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "qualité_texture"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "オーディオ",
|
||||
"tooltip": "動画に追加するオーディオです。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "作成される動画のビット深度。10ビットはグラデーションがより滑らかでバンディングが少なくなりますが、一部のプレーヤーや下流ノードではサポートされない場合があります。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "外部の3Dモデル(例:Rodin、Hunyuan3D、またはローカルファイル)をTripoにインポートし、Tripoのポストプロセスノード(Texture、Rig、Convert)で使用します。GLB形式を推奨します:テクスチャはファイルに埋め込まれている場合のみインポート時に保持されます。インポートしたモデルにテクスチャを付与するには、テクスチャプロンプトが必要です。",
|
||||
"display_name": "Tripo: Import Model",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "インポートする3Dモデル(GLB / FBX / OBJ / STL、最大150MB)。OBJおよびSTLファイルには埋め込みテクスチャがありません。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: マルチビューからモデル",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "テクスチャ付与のためのオプションのテキストガイダンス。インポートしたモデル(Tripo: Import Model)には参照画像がないため、実際には必須です。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "texture_quality"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "오디오",
|
||||
"tooltip": "비디오에 추가할 오디오입니다."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "생성된 비디오의 비트 깊이입니다. 10비트는 더 부드러운 그라데이션과 적은 밴딩을 제공하지만, 일부 플레이어나 후속 노드에서 지원되지 않을 수 있습니다."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "외부 3D 모델(예: Rodin, Hunyuan3D 또는 로컬 파일)을 Tripo로 가져와 Tripo의 후처리 노드(Texture, Rig, Convert)와 함께 사용할 수 있습니다. GLB 형식을 권장합니다: 텍스처가 파일에 임베드되어 있을 때만 가져오기가 가능합니다. 가져온 모델에 텍스처를 적용하려면 텍스처 프롬프트가 필요합니다.",
|
||||
"display_name": "Tripo: Import Model",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "가져올 3D 모델(GLB / FBX / OBJ / STL, 최대 150MB). OBJ와 STL 파일은 임베드된 텍스처를 포함하지 않습니다."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: 다중 뷰에서 모델 생성",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "텍스처 정렬"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "텍스처링을 위한 선택적 텍스트 가이드입니다. 가져온 모델(Tripo: Import Model)의 경우 실제로 필요하며, 색상을 추론할 소스 이미지가 없기 때문입니다."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "텍스처 품질"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "áudio",
|
||||
"tooltip": "O áudio a ser adicionado ao vídeo."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Profundidade de bits do vídeo criado. 10 bits mantém gradientes mais suaves com menos faixas, mas alguns players e nós subsequentes podem não suportar."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Importe um modelo 3D externo (por exemplo, do Rodin, Hunyuan3D ou de um arquivo local) para o Tripo para usá-lo com os nós de pós-processamento do Tripo: Texture, Rig, Convert. GLB é recomendado: as texturas só permanecem após a importação quando estão incorporadas no arquivo. Observe que texturizar um modelo importado requer um prompt de textura.",
|
||||
"display_name": "Tripo: Importar Modelo",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Modelo 3D para importar (GLB / FBX / OBJ / STL, até 150 MB). Arquivos OBJ e STL não possuem texturas incorporadas."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Multiview para Modelo",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "alinhamento_textura"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Orientação textual opcional para texturização. Necessário na prática para modelos importados (Tripo: Importar Modelo), que não possuem imagem de origem para inferir cores."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "qualidade_textura"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "аудио",
|
||||
"tooltip": "Аудио, которое будет добавлено к видео."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Глубина цвета создаваемого видео. 10-бит обеспечивает более плавные градиенты с меньшим количеством полос, но некоторые проигрыватели и последующие узлы могут не поддерживать этот формат."
|
||||
},
|
||||
"fps": {
|
||||
"name": "кадров в секунду"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Импорт внешней 3D-модели (например, из Rodin, Hunyuan3D или локального файла) в Tripo для использования с постобработкой Tripo: Текстурирование, Скелетирование, Конвертация. Рекомендуется GLB: текстуры сохраняются только при встраивании в файл. Обратите внимание, что для текстурирования импортированной модели требуется текстурный запрос.",
|
||||
"display_name": "Tripo: Импорт модели",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "3D-модель для импорта (GLB / FBX / OBJ / STL, до 150 МБ). Файлы OBJ и STL не содержат встроенных текстур."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Мультивью в модель",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "texture_alignment"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Необязательное текстовое описание для текстурирования. На практике требуется для импортированных моделей (Tripo: Импорт модели), которые не содержат исходного изображения для определения цветов."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "texture_quality"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "ses",
|
||||
"tooltip": "Videoya eklenecek ses."
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "Oluşturulan videonun bit derinliği. 10-bit, daha az bantlanma ile daha yumuşak geçişler sağlar, ancak bazı oynatıcılar ve sonraki düğümler bunu desteklemeyebilir."
|
||||
},
|
||||
"fps": {
|
||||
"name": "fps"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "fps",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "Harici bir 3D modeli (ör. Rodin, Hunyuan3D veya yerel bir dosyadan) Tripo'ya aktararak Tripo'nun son işlem düğümleriyle kullanın: Texture, Rig, Convert. GLB önerilir: dokular yalnızca dosyaya gömülü olduğunda aktarımda korunur. Aktarılan bir modelin dokulandırılması için bir doku istemi gerektiğini unutmayın.",
|
||||
"display_name": "Tripo: Model İçe Aktar",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "İçe aktarılacak 3D model (GLB / FBX / OBJ / STL, en fazla 150 MB). OBJ ve STL dosyalarında gömülü doku bulunmaz."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo: Çok Bakışlıdan Modele",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "doku_hizalama"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "Dokulandırma için isteğe bağlı metin rehberi. Pratikte, renkleri çıkarmak için kaynak görseli olmayan aktarılan modeller (Tripo: Model İçe Aktar) için gereklidir."
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "doku_kalitesi"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "音訊",
|
||||
"tooltip": "要加入影片的音訊。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "所建立影片的位元深度。10 位元可保持更平滑的漸層並減少色帶現象,但部分播放器與後續節點可能不支援。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "每秒影格數"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "每秒影格數",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "將外部 3D 模型(例如來自 Rodin、Hunyuan3D 或本機檔案)匯入 Tripo,以搭配 Tripo 的後處理節點使用:材質、骨架、轉換。建議使用 GLB 格式:僅當材質嵌入檔案時,匯入後才會保留材質。請注意,對匯入模型進行材質處理時需提供材質提示詞。",
|
||||
"display_name": "Tripo:匯入模型",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "要匯入的 3D 模型(GLB / FBX / OBJ / STL,最大 150 MB)。OBJ 與 STL 檔案不包含嵌入材質。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo:多視角轉模型",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "紋理對齊"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "材質處理的選用文字指引。實際上,對於匯入的模型(Tripo:匯入模型)必須提供,因為這些模型沒有來源圖像可推斷顏色。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "紋理品質"
|
||||
},
|
||||
|
||||
@@ -2904,10 +2904,6 @@
|
||||
"name": "音频",
|
||||
"tooltip": "要添加到视频中的音频。"
|
||||
},
|
||||
"bit_depth": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": "所创建视频的位深度。10位可以保持更平滑的渐变并减少色带,但部分播放器和下游节点可能不支持。"
|
||||
},
|
||||
"fps": {
|
||||
"name": "帧率"
|
||||
},
|
||||
@@ -5224,10 +5220,6 @@
|
||||
"2": {
|
||||
"name": "帧率",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "bit_depth",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19594,22 +19586,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoImportModelNode": {
|
||||
"description": "将外部3D模型(例如来自Rodin、Hunyuan3D或本地文件)导入Tripo,以便与Tripo的后处理节点(纹理、绑定、转换)一起使用。推荐使用GLB格式:只有嵌入文件的纹理才能在导入时保留。请注意,为导入的模型添加纹理需要提供纹理提示词。",
|
||||
"display_name": "Tripo:导入模型",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "要导入的3D模型(GLB / FBX / OBJ / STL,最大150MB)。OBJ和STL文件不包含嵌入纹理。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model task_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoMultiviewToModelNode": {
|
||||
"display_name": "Tripo:多视图转模型",
|
||||
"inputs": {
|
||||
@@ -20078,10 +20054,6 @@
|
||||
"texture_alignment": {
|
||||
"name": "纹理对齐"
|
||||
},
|
||||
"texture_prompt": {
|
||||
"name": "texture_prompt",
|
||||
"tooltip": "用于纹理生成的可选文本引导。对于导入的模型(Tripo:导入模型),实际操作中需要提供,因为这些模型没有可用于推断颜色的源图像。"
|
||||
},
|
||||
"texture_quality": {
|
||||
"name": "纹理质量"
|
||||
},
|
||||
|
||||
@@ -143,7 +143,7 @@ const { t } = useI18n()
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground"
|
||||
class="text-[2rem] leading-none font-semibold text-base-foreground"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
|
||||
@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayTitle: 'Missing Inputs',
|
||||
displayTitle: 'Missing Inputs (2)',
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
|
||||
@@ -6,6 +6,10 @@ import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
|
||||
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -340,12 +344,15 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_node':
|
||||
return {
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
displayTitle: formatCountTitle(
|
||||
source.isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
@@ -353,7 +360,10 @@ export function resolveMissingErrorMessage(
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
displayTitle: formatCountTitle(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
@@ -361,9 +371,12 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_model':
|
||||
return {
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
displayTitle: formatCountTitle(
|
||||
st(
|
||||
'rightSidePanel.missingModels.missingModelsTitle',
|
||||
'Missing Models'
|
||||
),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
@@ -372,9 +385,9 @@ export function resolveMissingErrorMessage(
|
||||
case 'missing_media':
|
||||
return {
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: st(
|
||||
'rightSidePanel.missingMedia.missingMediaTitle',
|
||||
'Missing Inputs'
|
||||
displayTitle: formatCountTitle(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="px-4 pb-2">
|
||||
<TransitionGroup
|
||||
tag="ul"
|
||||
name="list-scale"
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="flex min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="emit('locateNode', item.nodeId)"
|
||||
>
|
||||
{{ item.displayItemLabel }}
|
||||
@@ -25,7 +25,7 @@
|
||||
data-testid="missing-media-locate-button"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', {
|
||||
item: item.displayItemLabel
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-if="importableModelRows.length > 0"
|
||||
data-testid="missing-model-importable-rows"
|
||||
class="flex flex-col gap-1 overflow-hidden"
|
||||
class="flex flex-col gap-1 overflow-hidden py-2"
|
||||
>
|
||||
<MissingModelRow
|
||||
v-for="row in importableModelRows"
|
||||
@@ -19,7 +19,7 @@
|
||||
<div
|
||||
v-if="unsupportedModelRows.length > 0"
|
||||
data-testid="missing-model-import-not-supported-section"
|
||||
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
|
||||
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<p class="m-0 text-sm font-semibold text-warning-background">
|
||||
@@ -49,7 +49,7 @@
|
||||
data-testid="missing-model-download-all"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 min-w-0 flex-1 rounded-md text-xs"
|
||||
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
|
||||
@click="downloadAllModels"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
|
||||
|
||||
@@ -12,27 +12,27 @@
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
@click="handleToggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
|
||||
<span class="block min-w-0 text-sm/tight">
|
||||
<button
|
||||
v-if="hasModelLabelControl"
|
||||
ref="modelLabelControl"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
:title="displayModelName"
|
||||
@click="handleModelLabelClick"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 font-normal wrap-break-word text-base-foreground"
|
||||
class="font-normal wrap-break-word text-base-foreground"
|
||||
:title="displayModelName"
|
||||
>
|
||||
{{ displayModelName }}
|
||||
@@ -48,14 +48,14 @@
|
||||
<span
|
||||
v-if="hasMultipleReferences"
|
||||
data-testid="missing-model-reference-count"
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
{{ model.referencingNodes.length }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
|
||||
:aria-label="linkLabel"
|
||||
:title="linkLabel"
|
||||
@click="copyModelLink"
|
||||
@@ -82,7 +82,7 @@
|
||||
data-testid="missing-model-import"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
{{ t('g.import') }}
|
||||
@@ -123,7 +123,7 @@
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
:aria-label="`${t('g.download')} ${model.name}`"
|
||||
@click="handleDownload"
|
||||
>
|
||||
@@ -137,7 +137,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="handleLocatePrimary"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
@@ -149,7 +149,7 @@
|
||||
v-if="showReferenceList"
|
||||
:class="
|
||||
cn(
|
||||
'm-0 list-none p-0',
|
||||
'm-0 list-none space-y-0.5 p-0',
|
||||
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
|
||||
)
|
||||
"
|
||||
@@ -159,10 +159,10 @@
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-h-8 min-w-0 items-center gap-2">
|
||||
<div class="flex min-h-6 min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
{{
|
||||
@@ -174,7 +174,7 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
|
||||
|
||||
@@ -3,26 +3,21 @@
|
||||
<div class="flex min-h-8 w-full items-center gap-1">
|
||||
<Button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-expand"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
|
||||
:class="
|
||||
cn(
|
||||
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
:aria-expanded="expanded"
|
||||
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -32,7 +27,7 @@
|
||||
<button
|
||||
v-if="hasMultipleNodeTypes"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
:title="group.type"
|
||||
:aria-label="titleToggleAriaLabel"
|
||||
:aria-expanded="expanded"
|
||||
@@ -43,7 +38,7 @@
|
||||
<button
|
||||
v-else-if="primaryLocatableNodeType"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
:title="group.type"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -51,7 +46,7 @@
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
|
||||
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
|
||||
:title="group.type"
|
||||
>
|
||||
{{ group.type }}
|
||||
@@ -60,7 +55,7 @@
|
||||
v-if="hasMultipleNodeTypes"
|
||||
data-testid="swap-node-group-count"
|
||||
role="img"
|
||||
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
|
||||
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
|
||||
>
|
||||
{{ group.nodeTypes.length }}
|
||||
@@ -85,7 +80,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="handleReplaceNode"
|
||||
>
|
||||
<i
|
||||
@@ -101,7 +96,7 @@
|
||||
v-if="primaryLocatableNodeType"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(primaryLocatableNodeType)"
|
||||
>
|
||||
@@ -121,14 +116,14 @@
|
||||
<button
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
type="button"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
|
||||
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs/relaxed wrap-break-word text-muted-foreground"
|
||||
class="text-sm/relaxed wrap-break-word text-muted-foreground"
|
||||
>
|
||||
{{ getLabel(nodeType) }}
|
||||
</span>
|
||||
@@ -137,7 +132,7 @@
|
||||
v-if="isLocatableNodeType(nodeType)"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="locateNodeLabel"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="mt-2 px-4 pb-2">
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
|
||||
@@ -53,16 +53,13 @@ describe('useSettingsDialog', () => {
|
||||
isCloudRef.value = false
|
||||
})
|
||||
|
||||
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
|
||||
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
|
||||
useSettingsDialog().show()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('global-settings')
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('full')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[1280px]')
|
||||
expect(args.dialogComponentProps.contentClass).not.toContain(
|
||||
'max-w-[960px]'
|
||||
)
|
||||
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
const DIALOG_KEY = 'global-settings'
|
||||
|
||||
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
|
||||
const SETTINGS_CONTENT_CLASS =
|
||||
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
|
||||
|
||||
export function useSettingsDialog() {
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'
|
||||
|
||||
const showCreateWorkspaceDialog = vi.fn()
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userDisplayName: ref('Liz'),
|
||||
userEmail: ref('liz@example.com'),
|
||||
userPhotoUrl: ref(null),
|
||||
handleSignOut: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: ref(true),
|
||||
isFreeTier: ref(false),
|
||||
subscription: ref(null),
|
||||
balance: ref(null),
|
||||
isLoading: ref(false),
|
||||
fetchBalance: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canTopUp: false,
|
||||
canManageSubscription: false
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ showPricingTable: vi.fn() })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: () => ({ show: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showCreateWorkspaceDialog,
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
buildDocsUrl: vi.fn(() => 'https://docs.comfy.org'),
|
||||
docsPaths: { partnerNodesPricing: 'partner-nodes' }
|
||||
})
|
||||
}))
|
||||
|
||||
const WorkspaceSwitcherPopoverStub = defineComponent({
|
||||
emits: ['select', 'create'],
|
||||
template: `
|
||||
<div>
|
||||
<button data-testid="stub-select-workspace" @click="$emit('select')" />
|
||||
<button data-testid="stub-create-workspace" @click="$emit('create')" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(CurrentUserPopoverWorkspace, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
teamWorkspace: {
|
||||
initState: 'ready',
|
||||
activeWorkspaceId: 'ws-personal'
|
||||
}
|
||||
}
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
},
|
||||
stubs: {
|
||||
WorkspaceSwitcherPopover: WorkspaceSwitcherPopoverStub,
|
||||
SubscribeButton: true,
|
||||
UserAvatar: true,
|
||||
WorkspaceProfilePic: true,
|
||||
Skeleton: true,
|
||||
Divider: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('CurrentUserPopoverWorkspace', () => {
|
||||
it('toggles the workspace switcher panel from the selector row', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('workspace-switcher-panel')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('workspace-switcher-trigger'))
|
||||
expect(screen.getByTestId('workspace-switcher-panel')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('workspace-switcher-trigger'))
|
||||
expect(
|
||||
screen.queryByTestId('workspace-switcher-panel')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the switcher panel after selecting a workspace', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('workspace-switcher-trigger'))
|
||||
await user.click(screen.getByTestId('stub-select-workspace'))
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('workspace-switcher-panel')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the create-workspace dialog and closes the popover on create', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderComponent()
|
||||
|
||||
await user.click(screen.getByTestId('workspace-switcher-trigger'))
|
||||
await user.click(screen.getByTestId('stub-create-workspace'))
|
||||
|
||||
expect(showCreateWorkspaceDialog).toHaveBeenCalled()
|
||||
expect(emitted('close')).toHaveLength(1)
|
||||
expect(
|
||||
screen.queryByTestId('workspace-switcher-panel')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -24,38 +24,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="workspaceSwitcherTrigger"
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="workspace-switcher-trigger"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isWorkspaceSwitcherOpen"
|
||||
ref="workspaceSwitcherPanel"
|
||||
class="absolute top-0 right-full z-10 mr-4 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
data-testid="workspace-switcher-panel"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="isWorkspaceSwitcherOpen = false"
|
||||
@create="handleCreateWorkspace"
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
@@ -215,11 +214,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
@@ -247,17 +246,7 @@ const {
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const isWorkspaceSwitcherOpen = ref(false)
|
||||
const workspaceSwitcherTrigger = useTemplateRef('workspaceSwitcherTrigger')
|
||||
const workspaceSwitcherPanel = useTemplateRef('workspaceSwitcherPanel')
|
||||
|
||||
onClickOutside(
|
||||
workspaceSwitcherPanel,
|
||||
() => {
|
||||
isWorkspaceSwitcherOpen.value = false
|
||||
},
|
||||
{ ignore: [workspaceSwitcherTrigger] }
|
||||
)
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@@ -369,13 +358,13 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
isWorkspaceSwitcherOpen.value = false
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = () => {
|
||||
isWorkspaceSwitcherOpen.value = !isWorkspaceSwitcherOpen.value
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
const refreshBalance = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
|
||||
@@ -9,14 +10,8 @@ vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
|
||||
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
|
||||
}))
|
||||
|
||||
const billingMocks = vi.hoisted(() => ({
|
||||
subscription: {
|
||||
value: null as { tier: string; duration: string } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ subscription: billingMocks.subscription })
|
||||
useBillingContext: () => ({ subscription: ref(null) })
|
||||
}))
|
||||
|
||||
const LONG_WORKSPACE_NAME =
|
||||
@@ -31,14 +26,9 @@ const i18n = createI18n({
|
||||
personal: 'Personal',
|
||||
roleOwner: 'Owner',
|
||||
roleMember: 'Member',
|
||||
createWorkspace: 'Create a team workspace',
|
||||
createWorkspace: 'Create new workspace',
|
||||
maxWorkspacesReached:
|
||||
'You can only own 10 workspaces. Delete one to create a new one.'
|
||||
},
|
||||
subscription: {
|
||||
tiers: {
|
||||
pro: { name: 'Pro' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,12 +47,7 @@ function createWorkspaceState(overrides: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
overrides: {
|
||||
activeWorkspaceId?: string
|
||||
workspaces?: Record<string, unknown>[]
|
||||
} = {}
|
||||
) {
|
||||
function renderComponent() {
|
||||
return render(WorkspaceSwitcherPopover, {
|
||||
global: {
|
||||
plugins: [
|
||||
@@ -70,9 +55,9 @@ function renderComponent(
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
teamWorkspace: {
|
||||
activeWorkspaceId: overrides.activeWorkspaceId ?? 'ws-personal',
|
||||
activeWorkspaceId: 'ws-personal',
|
||||
isFetchingWorkspaces: false,
|
||||
workspaces: overrides.workspaces ?? [
|
||||
workspaces: [
|
||||
createWorkspaceState({
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
@@ -99,10 +84,6 @@ function renderComponent(
|
||||
}
|
||||
|
||||
describe('WorkspaceSwitcherPopover', () => {
|
||||
beforeEach(() => {
|
||||
billingMocks.subscription.value = null
|
||||
})
|
||||
|
||||
it('exposes the full team workspace name as a tooltip on the row', () => {
|
||||
renderComponent()
|
||||
|
||||
@@ -110,55 +91,4 @@ describe('WorkspaceSwitcherPopover', () => {
|
||||
|
||||
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
|
||||
})
|
||||
|
||||
it('does not render a tier badge on team workspace rows', () => {
|
||||
billingMocks.subscription.value = { tier: 'PRO', duration: 'MONTHLY' }
|
||||
|
||||
renderComponent({
|
||||
activeWorkspaceId: 'ws-team',
|
||||
workspaces: [
|
||||
createWorkspaceState({
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}),
|
||||
createWorkspaceState({
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
role: 'owner',
|
||||
isSubscribed: true,
|
||||
subscriptionTier: 'PRO'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByText('Team Comfy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pro')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the tier badge on a subscribed personal workspace row', () => {
|
||||
renderComponent({
|
||||
activeWorkspaceId: 'ws-team',
|
||||
workspaces: [
|
||||
createWorkspaceState({
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner',
|
||||
isSubscribed: true,
|
||||
subscriptionTier: 'PRO'
|
||||
}),
|
||||
createWorkspaceState({
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
role: 'owner'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,8 +183,6 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
}
|
||||
|
||||
function resolveTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
if (workspace.type !== 'personal') return null
|
||||
|
||||
if (isCurrentWorkspace(workspace)) {
|
||||
return currentSubscriptionTierName.value || null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
@@ -24,13 +24,13 @@
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="focus:ring-secondary-foreground h-10 w-full rounded-lg border-none bg-secondary-background px-4 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
|
||||
class="focus:ring-secondary-foreground w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:ring-1 focus:outline-none"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
|
||||
@@ -149,12 +149,12 @@ export function useSubscriptionCheckout(emit: {
|
||||
response.payment_method_url
|
||||
) {
|
||||
window.open(response.payment_method_url, '_blank')
|
||||
void billingOperationStore.startOperation(
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
} else if (response.status === 'pending_payment') {
|
||||
void billingOperationStore.startOperation(
|
||||
billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
import { createApp, defineComponent, effectScope, h } from 'vue'
|
||||
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
import type { BillingActions, BillingState } from '@/composables/billing/types'
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingStatus: vi.fn(),
|
||||
@@ -23,7 +24,7 @@ const mockBillingPlans = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const mockShow = vi.hoisted(() => vi.fn())
|
||||
const mockStartOperation = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateActiveWorkspace = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: mockWorkspaceApi
|
||||
@@ -42,9 +43,9 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
startOperation: mockStartOperation
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -399,44 +400,54 @@ describe('useWorkspaceBilling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSubscription', () => {
|
||||
function operation(
|
||||
overrides: Partial<{
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'timeout'
|
||||
errorMessage: string | null
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
opId: 'op-cancel',
|
||||
type: 'cancel' as const,
|
||||
status: overrides.status ?? ('succeeded' as const),
|
||||
errorMessage: overrides.errorMessage ?? null,
|
||||
startedAt: 0
|
||||
}
|
||||
}
|
||||
describe('cancelSubscription polling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('drives the shared billing operation poller with a cancel op', async () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('updates workspace store when op succeeds', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-cancel',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockStartOperation.mockResolvedValue(operation())
|
||||
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
|
||||
id: 'op-cancel',
|
||||
status: 'succeeded',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue({
|
||||
...activeStatus,
|
||||
is_active: false,
|
||||
subscription_status: 'canceled'
|
||||
})
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.cancelSubscription()
|
||||
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-cancel', 'cancel')
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledWith(
|
||||
'op-cancel'
|
||||
)
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: false
|
||||
})
|
||||
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws the op error message when the cancel op fails', async () => {
|
||||
it('rethrows when the op reports failure', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-fail',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockStartOperation.mockResolvedValue(
|
||||
operation({ status: 'failed', errorMessage: 'processor rejected' })
|
||||
)
|
||||
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
|
||||
id: 'op-fail',
|
||||
status: 'failed',
|
||||
started_at: '2026-04-01T00:00:00Z',
|
||||
error_message: 'processor rejected'
|
||||
})
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
@@ -444,44 +455,88 @@ describe('useWorkspaceBilling', () => {
|
||||
'processor rejected'
|
||||
)
|
||||
expect(billing.error.value).toBe('processor rejected')
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when the cancel op times out', async () => {
|
||||
it('schedules the second poll at the 2000ms backoff boundary', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-timeout',
|
||||
billing_op_id: 'op-slow',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockStartOperation.mockResolvedValue(
|
||||
operation({
|
||||
status: 'timeout',
|
||||
errorMessage: 'billingOperation.cancelTimeout'
|
||||
const pendingResponse = {
|
||||
id: 'op-slow',
|
||||
status: 'pending' as const,
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
}
|
||||
mockWorkspaceApi.getBillingOpStatus
|
||||
.mockResolvedValueOnce(pendingResponse)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'op-slow',
|
||||
status: 'succeeded',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue({
|
||||
...activeStatus,
|
||||
is_active: false
|
||||
})
|
||||
|
||||
const billing = setupBilling()
|
||||
const cancelPromise = billing.cancelSubscription()
|
||||
|
||||
await expect(billing.cancelSubscription()).rejects.toThrow(
|
||||
'billingOperation.cancelTimeout'
|
||||
)
|
||||
// First poll runs synchronously inside cancelSubscription.
|
||||
await cancelPromise
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Boundary check: still only 1 call just before the 2000ms mark.
|
||||
await vi.advanceTimersByTimeAsync(1999)
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Crossing 2000ms total fires the scheduled retry.
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-success op omits errorMessage', async () => {
|
||||
it('caps the backoff at 5000ms once 2^attempt exceeds the cap', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-noerr',
|
||||
billing_op_id: 'op-cap',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockStartOperation.mockResolvedValue(
|
||||
operation({ status: 'failed', errorMessage: null })
|
||||
)
|
||||
const pending = {
|
||||
id: 'op-cap',
|
||||
status: 'pending' as const,
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
}
|
||||
mockWorkspaceApi.getBillingOpStatus
|
||||
.mockResolvedValueOnce(pending) // #1, schedules +2000ms
|
||||
.mockResolvedValueOnce(pending) // #2 at t=2000, schedules +4000ms
|
||||
.mockResolvedValueOnce(pending) // #3 at t=6000, schedules capped +5000ms
|
||||
.mockResolvedValueOnce({
|
||||
id: 'op-cap',
|
||||
status: 'succeeded',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.cancelSubscription()
|
||||
|
||||
await expect(billing.cancelSubscription()).rejects.toThrow(
|
||||
'Failed to cancel subscription'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(2000) // fires #2
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000) // fires #3 at t=6000
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
|
||||
|
||||
// After #3 attempt=3, next delay should be capped at 5000ms (not 8000).
|
||||
await vi.advanceTimersByTimeAsync(4999)
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('propagates the error and skips polling when the cancel API fails', async () => {
|
||||
it('propagates error before polling when the cancel API itself fails', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockRejectedValue(
|
||||
new Error('API down')
|
||||
)
|
||||
@@ -490,7 +545,8 @@ describe('useWorkspaceBilling', () => {
|
||||
|
||||
await expect(billing.cancelSubscription()).rejects.toThrow('API down')
|
||||
expect(billing.error.value).toBe('API down')
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).not.toHaveBeenCalled()
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message when cancel rejects with a non-Error', async () => {
|
||||
@@ -501,6 +557,71 @@ describe('useWorkspaceBilling', () => {
|
||||
await expect(billing.cancelSubscription()).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to cancel subscription')
|
||||
})
|
||||
|
||||
it('stops polling after 30 attempts and refreshes status without marking unsubscribed', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-stuck',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
|
||||
id: 'op-stuck',
|
||||
status: 'pending',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.cancelSubscription()
|
||||
|
||||
// Advance well past all scheduled polls (worst-case ~146s).
|
||||
await vi.advanceTimersByTimeAsync(200_000)
|
||||
|
||||
expect(mockWorkspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(30)
|
||||
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops polling when the host component is unmounted', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-dispose',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
|
||||
id: 'op-dispose',
|
||||
status: 'pending',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
|
||||
let billing: (BillingState & BillingActions) | undefined
|
||||
const HostComponent = defineComponent({
|
||||
setup() {
|
||||
billing = useWorkspaceBilling()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
const host = document.createElement('div')
|
||||
const app = createApp(HostComponent)
|
||||
app.mount(host)
|
||||
|
||||
if (!billing) throw new Error('composable not initialized')
|
||||
const cancelPromise = billing.cancelSubscription().catch(() => undefined)
|
||||
await cancelPromise
|
||||
|
||||
// Cross one backoff interval so the second poll is actually scheduled
|
||||
// and then confirm that unmount freezes the counter across subsequent ticks.
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
const callsBeforeUnmount =
|
||||
mockWorkspaceApi.getBillingOpStatus.mock.calls.length
|
||||
expect(callsBeforeUnmount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
app.unmount()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
expect(mockWorkspaceApi.getBillingOpStatus.mock.calls.length).toBe(
|
||||
callsBeforeUnmount
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubscribe', () => {
|
||||
@@ -706,4 +827,43 @@ describe('useWorkspaceBilling', () => {
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollCancelStatus error paths', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses a default error message when failed status omits error_message', async () => {
|
||||
mockWorkspaceApi.cancelSubscription.mockResolvedValue({
|
||||
billing_op_id: 'op-noerr',
|
||||
cancel_at: '2026-06-01T00:00:00Z'
|
||||
})
|
||||
mockWorkspaceApi.getBillingOpStatus.mockResolvedValue({
|
||||
id: 'op-noerr',
|
||||
status: 'failed',
|
||||
started_at: '2026-04-01T00:00:00Z'
|
||||
// intentionally no error_message
|
||||
})
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.cancelSubscription()).rejects.toThrow(
|
||||
'Failed to cancel subscription'
|
||||
)
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Intentionally NOT covered: a rejection on a later scheduled poll is
|
||||
// emitted from a void-discarded poll() inside setTimeout, so it surfaces
|
||||
// as an unhandled rejection that cancelSubscription has already returned
|
||||
// from. Codifying that as "polling stops cleanly" requires installing a
|
||||
// process unhandledRejection handler to hide the evidence — which would
|
||||
// bless a real bug: the dialog can already show success while the
|
||||
// backing op silently fails. Fix in the source (retry transient poll
|
||||
// failures or surface a pending/error state) before adding coverage here.
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
*/
|
||||
export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const billingPlans = useBillingPlans()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
@@ -83,6 +83,68 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
|
||||
const pendingCancelOpId = ref<string | null>(null)
|
||||
let cancelPollTimeout: number | null = null
|
||||
|
||||
const stopCancelPolling = () => {
|
||||
if (cancelPollTimeout !== null) {
|
||||
window.clearTimeout(cancelPollTimeout)
|
||||
cancelPollTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCancelStatus(opId: string): Promise<void> {
|
||||
stopCancelPolling()
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempt = 0
|
||||
const poll = async () => {
|
||||
if (pendingCancelOpId.value !== opId) return
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
if (response.status === 'succeeded') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
workspaceStore.updateActiveWorkspace({
|
||||
isSubscribed: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw new Error(
|
||||
response.error_message ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
if (attempt >= maxAttempts) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw err
|
||||
}
|
||||
|
||||
cancelPollTimeout = window.setTimeout(
|
||||
() => {
|
||||
void poll()
|
||||
},
|
||||
Math.min(1000 * 2 ** attempt, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
@@ -197,16 +259,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.cancelSubscription()
|
||||
const operation = await billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'cancel'
|
||||
)
|
||||
|
||||
if (operation.status !== 'succeeded') {
|
||||
throw new Error(
|
||||
operation.errorMessage ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
pendingCancelOpId.value = response.billing_op_id
|
||||
await pollCancelStatus(response.billing_op_id)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to cancel subscription'
|
||||
@@ -270,6 +324,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
|
||||
@@ -33,11 +33,9 @@ vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
const mockSettingsDialogShow = vi.fn()
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: () => ({
|
||||
show: mockSettingsDialogShow,
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
})
|
||||
@@ -57,14 +55,6 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockUpdateActiveWorkspace = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
updateActiveWorkspace: mockUpdateActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingOperationStore } from './billingOperationStore'
|
||||
@@ -89,7 +79,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.operations.size).toBe(1)
|
||||
const operation = store.getOperation('op-1')
|
||||
@@ -107,34 +97,13 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.operations.size).toBe(1)
|
||||
expect(store.getOperation('op-1')?.type).toBe('subscription')
|
||||
})
|
||||
|
||||
it('returns the in-flight terminal promise for duplicate starts', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const first = store.startOperation('op-1', 'cancel')
|
||||
const second = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const [firstOutcome, secondOutcome] = await Promise.all([first, second])
|
||||
expect(firstOutcome.status).toBe('succeeded')
|
||||
expect(secondOutcome.status).toBe('succeeded')
|
||||
|
||||
const afterTerminal = await store.startOperation('op-1', 'cancel')
|
||||
expect(afterTerminal.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('shows immediate processing toast for subscription operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
@@ -143,7 +112,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'info',
|
||||
@@ -160,7 +129,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'info',
|
||||
@@ -180,7 +149,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -207,7 +176,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -222,7 +191,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -237,7 +206,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -256,7 +225,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
const receivedToast = mockToastAdd.mock.calls[0][0]
|
||||
|
||||
@@ -277,7 +246,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -301,7 +270,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -322,7 +291,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -347,7 +316,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(121_000)
|
||||
await vi.runAllTimersAsync()
|
||||
@@ -359,114 +328,6 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel operations', () => {
|
||||
it('does not show a processing toast for cancel operations', () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'cancel')
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves with the succeeded operation and refreshes status', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const operation = await terminal
|
||||
|
||||
expect(operation.status).toBe('succeeded')
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
|
||||
isSubscribed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the terminal outcome even when the post-success refresh fails', async () => {
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const operation = await terminal
|
||||
|
||||
expect(operation.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('does not open the settings dialog or toast on cancel success', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'succeeded',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await terminal
|
||||
|
||||
expect(mockSettingsDialogShow).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves with a failed operation and default message, no toast', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'failed',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const operation = await terminal
|
||||
|
||||
expect(operation.status).toBe('failed')
|
||||
expect(operation.errorMessage).toBe('billingOperation.cancelFailed')
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves with a timeout operation after 2 minutes, no toast', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
id: 'op-1',
|
||||
status: 'pending',
|
||||
started_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
const terminal = store.startOperation('op-1', 'cancel')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(121_000)
|
||||
await vi.runAllTimersAsync()
|
||||
const operation = await terminal
|
||||
|
||||
expect(operation.status).toBe('timeout')
|
||||
expect(operation.errorMessage).toBe('billingOperation.cancelTimeout')
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exponential backoff', () => {
|
||||
it('uses exponential backoff for polling intervals', async () => {
|
||||
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
|
||||
@@ -476,7 +337,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workspaceApi.getBillingOpStatus).toHaveBeenCalledTimes(1)
|
||||
@@ -496,7 +357,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
@@ -523,7 +384,7 @@ describe('billingOperationStore', () => {
|
||||
} satisfies BillingOpStatusResponse)
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getOperation('op-1')?.status).toBe('pending')
|
||||
@@ -545,7 +406,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -569,8 +430,8 @@ describe('billingOperationStore', () => {
|
||||
)
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
void store.startOperation('op-2', 'topup')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-2', 'topup')
|
||||
|
||||
expect(store.operations.size).toBe(2)
|
||||
expect(store.hasPendingOperations).toBe(true)
|
||||
@@ -601,7 +462,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.isSettingUp).toBe(true)
|
||||
})
|
||||
@@ -614,7 +475,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -629,7 +490,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.isSettingUp).toBe(false)
|
||||
})
|
||||
@@ -644,7 +505,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
expect(store.isAddingCredits).toBe(true)
|
||||
})
|
||||
@@ -657,7 +518,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'topup')
|
||||
store.startOperation('op-1', 'topup')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -672,7 +533,7 @@ describe('billingOperationStore', () => {
|
||||
})
|
||||
|
||||
const store = useBillingOperationStore()
|
||||
void store.startOperation('op-1', 'subscription')
|
||||
store.startOperation('op-1', 'subscription')
|
||||
|
||||
expect(store.isAddingCredits).toBe(false)
|
||||
})
|
||||
|
||||
@@ -4,11 +4,10 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const INITIAL_INTERVAL_MS = 1000
|
||||
@@ -16,7 +15,7 @@ const MAX_INTERVAL_MS = 8000
|
||||
const BACKOFF_MULTIPLIER = 1.5
|
||||
const TIMEOUT_MS = 120_000 // 2 minutes
|
||||
|
||||
type OperationType = 'subscription' | 'topup' | 'cancel'
|
||||
type OperationType = 'subscription' | 'topup'
|
||||
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
|
||||
|
||||
interface BillingOperation {
|
||||
@@ -27,15 +26,11 @@ interface BillingOperation {
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
type TerminalResolver = (operation: BillingOperation) => void
|
||||
|
||||
export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
const operations = ref<Map<string, BillingOperation>>(new Map())
|
||||
const timeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const intervals = new Map<string, number>()
|
||||
const receivedToasts = new Map<string, ToastMessageOptions>()
|
||||
const terminalResolvers = new Map<string, TerminalResolver>()
|
||||
const terminalPromises = new Map<string, Promise<BillingOperation>>()
|
||||
|
||||
const hasPendingOperations = computed(() =>
|
||||
[...operations.value.values()].some((op) => op.status === 'pending')
|
||||
@@ -57,14 +52,8 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
return operations.value.get(opId)
|
||||
}
|
||||
|
||||
function startOperation(
|
||||
opId: string,
|
||||
type: OperationType
|
||||
): Promise<BillingOperation> {
|
||||
const existing = operations.value.get(opId)
|
||||
if (existing) {
|
||||
return terminalPromises.get(opId) ?? Promise.resolve(existing)
|
||||
}
|
||||
function startOperation(opId: string, type: OperationType) {
|
||||
if (operations.value.has(opId)) return
|
||||
|
||||
const operation: BillingOperation = {
|
||||
opId,
|
||||
@@ -77,29 +66,21 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
operations.value = new Map(operations.value).set(opId, operation)
|
||||
intervals.set(opId, INITIAL_INTERVAL_MS)
|
||||
|
||||
if (type !== 'cancel') {
|
||||
const messageKey =
|
||||
type === 'subscription'
|
||||
? 'billingOperation.subscriptionProcessing'
|
||||
: 'billingOperation.topupProcessing'
|
||||
// Show immediate feedback toast (persists until operation completes)
|
||||
const messageKey =
|
||||
type === 'subscription'
|
||||
? 'billingOperation.subscriptionProcessing'
|
||||
: 'billingOperation.topupProcessing'
|
||||
|
||||
const toastMessage: ToastMessageOptions = {
|
||||
severity: 'info',
|
||||
summary: t(messageKey),
|
||||
group: 'billing-operation'
|
||||
}
|
||||
receivedToasts.set(opId, toastMessage)
|
||||
useToastStore().add(toastMessage)
|
||||
const toastMessage: ToastMessageOptions = {
|
||||
severity: 'info',
|
||||
summary: t(messageKey),
|
||||
group: 'billing-operation'
|
||||
}
|
||||
|
||||
const terminal = new Promise<BillingOperation>((resolve) => {
|
||||
terminalResolvers.set(opId, resolve)
|
||||
})
|
||||
terminalPromises.set(opId, terminal)
|
||||
receivedToasts.set(opId, toastMessage)
|
||||
useToastStore().add(toastMessage)
|
||||
|
||||
void poll(opId)
|
||||
|
||||
return terminal
|
||||
}
|
||||
|
||||
async function poll(opId: string) {
|
||||
@@ -158,17 +139,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
}
|
||||
|
||||
const billingContext = useBillingContext()
|
||||
await Promise.allSettled([
|
||||
await Promise.all([
|
||||
billingContext.fetchStatus(),
|
||||
billingContext.fetchBalance()
|
||||
])
|
||||
|
||||
if (operation.type === 'cancel') {
|
||||
useTeamWorkspaceStore().updateActiveWorkspace({ isSubscribed: false })
|
||||
resolveTerminal(opId)
|
||||
return
|
||||
}
|
||||
|
||||
// Close any open billing dialogs and show settings
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.closeDialog({ key: 'subscription-required' })
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
@@ -185,70 +161,43 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
summary: t(messageKey),
|
||||
life: 5000
|
||||
})
|
||||
|
||||
resolveTerminal(opId)
|
||||
}
|
||||
|
||||
function handleFailure(opId: string, errorMessage: string | null) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
const defaultMessage = failureMessage(operation.type)
|
||||
const defaultMessage =
|
||||
operation.type === 'subscription'
|
||||
? t('billingOperation.subscriptionFailed')
|
||||
: t('billingOperation.topupFailed')
|
||||
|
||||
updateOperationStatus(opId, 'failed', errorMessage ?? defaultMessage)
|
||||
cleanup(opId)
|
||||
|
||||
if (operation.type !== 'cancel') {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: defaultMessage,
|
||||
detail: errorMessage ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
resolveTerminal(opId)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: defaultMessage,
|
||||
detail: errorMessage ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
function handleTimeout(opId: string) {
|
||||
const operation = operations.value.get(opId)
|
||||
if (!operation) return
|
||||
|
||||
const message = timeoutMessage(operation.type)
|
||||
const message =
|
||||
operation.type === 'subscription'
|
||||
? t('billingOperation.subscriptionTimeout')
|
||||
: t('billingOperation.topupTimeout')
|
||||
|
||||
updateOperationStatus(opId, 'timeout', message)
|
||||
cleanup(opId)
|
||||
|
||||
if (operation.type !== 'cancel') {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: message
|
||||
})
|
||||
}
|
||||
|
||||
resolveTerminal(opId)
|
||||
}
|
||||
|
||||
function failureMessage(type: OperationType) {
|
||||
if (type === 'subscription') return t('billingOperation.subscriptionFailed')
|
||||
if (type === 'topup') return t('billingOperation.topupFailed')
|
||||
return t('billingOperation.cancelFailed')
|
||||
}
|
||||
|
||||
function timeoutMessage(type: OperationType) {
|
||||
if (type === 'subscription')
|
||||
return t('billingOperation.subscriptionTimeout')
|
||||
if (type === 'topup') return t('billingOperation.topupTimeout')
|
||||
return t('billingOperation.cancelTimeout')
|
||||
}
|
||||
|
||||
function resolveTerminal(opId: string) {
|
||||
const resolve = terminalResolvers.get(opId)
|
||||
const operation = operations.value.get(opId)
|
||||
if (resolve && operation) {
|
||||
resolve(operation)
|
||||
}
|
||||
terminalResolvers.delete(opId)
|
||||
terminalPromises.delete(opId)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: message
|
||||
})
|
||||
}
|
||||
|
||||
function updateOperationStatus(
|
||||
@@ -284,8 +233,6 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
|
||||
const newMap = new Map(operations.value)
|
||||
newMap.delete(opId)
|
||||
operations.value = newMap
|
||||
terminalResolvers.delete(opId)
|
||||
terminalPromises.delete(opId)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -52,22 +52,6 @@ interface CustomDialogComponentProps {
|
||||
* PrimeVue path — use `pt.mask` for that renderer.
|
||||
*/
|
||||
overlayClass?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the Reka-UI `DialogHeader` element on the non-headless
|
||||
* path. Ignored on the PrimeVue path — use `pt.header` for that renderer.
|
||||
*/
|
||||
headerClass?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the wrapper around the content component on the Reka-UI
|
||||
* non-headless path. Ignored on the PrimeVue path — use `pt.content` for
|
||||
* that renderer.
|
||||
*/
|
||||
bodyClass?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the Reka-UI `DialogFooter` element on the non-headless
|
||||
* path. Ignored on the PrimeVue path — use `pt.footer` for that renderer.
|
||||
*/
|
||||
footerClass?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
export type DialogComponentProps = Record<string, unknown> &
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { mapKeys } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -359,12 +358,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
function restoreOutputs(
|
||||
outputs: Record<string, ExecutedWsMessage['output']>
|
||||
) {
|
||||
const parsedOutputs = mapKeys(
|
||||
outputs,
|
||||
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
|
||||
)
|
||||
app.nodeOutputs = parsedOutputs
|
||||
nodeOutputs.value = { ...parsedOutputs }
|
||||
app.nodeOutputs = outputs
|
||||
nodeOutputs.value = { ...outputs }
|
||||
}
|
||||
|
||||
function updateNodeImages(node: LGraphNode) {
|
||||
|
||||
Reference in New Issue
Block a user