mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
26 Commits
backup/fe-
...
codex/part
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3562a66d1f | ||
|
|
68bea387e1 | ||
|
|
5322dd90a2 | ||
|
|
5c92d4b521 | ||
|
|
f15710a43f | ||
|
|
ac126f3c59 | ||
|
|
957aafb9e8 | ||
|
|
3dfc5b6b52 | ||
|
|
49130c5767 | ||
|
|
8f5a9cc6bb | ||
|
|
d82d9f30d2 | ||
|
|
300901c793 | ||
|
|
ab646e7401 | ||
|
|
58048b9f9f | ||
|
|
9553b7a579 | ||
|
|
563eda5862 | ||
|
|
1c78ea091f | ||
|
|
7d6aa42019 | ||
|
|
6bddc2f9ae | ||
|
|
65c0d9fe54 | ||
|
|
8fdbd1161f | ||
|
|
9caaa9e98b | ||
|
|
1a98362984 | ||
|
|
ab33746b3e | ||
|
|
55c4e807a1 | ||
|
|
74147d7ee2 |
@@ -41,9 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Send local dev PostHog events to the staging project (cloud mode only).
|
||||
# VITE_POSTHOG_PROJECT_TOKEN=
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
@@ -354,7 +354,9 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
)
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deselectAllButton = page.getByRole('button', {
|
||||
name: 'Deselect all'
|
||||
})
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { AgentWsEvent } from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
|
||||
import {
|
||||
DRAFT_PATCH,
|
||||
MESSAGE_DELTA_EVENT,
|
||||
MESSAGE_DONE_EVENT,
|
||||
THINKING_EVENT,
|
||||
TOOL_CALL_EVENT,
|
||||
mockAgentBoot
|
||||
} from '@e2e/tests/agent/agentPanelMocks'
|
||||
|
||||
type AgentFixtures = {
|
||||
agentFlagEnabled: boolean
|
||||
postedMessages: string[]
|
||||
}
|
||||
|
||||
const agentCloudFixture = comfyPageFixture.extend<AgentFixtures>({
|
||||
agentFlagEnabled: [true, { option: true }],
|
||||
postedMessages: async ({}, use) => {
|
||||
await use([])
|
||||
},
|
||||
page: async ({ page, agentFlagEnabled, postedMessages }, use) => {
|
||||
await mockAgentBoot(page, { agentFlag: agentFlagEnabled, postedMessages })
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
const test = mergeTests(agentCloudFixture, webSocketFixture)
|
||||
|
||||
const OPEN_AGENT_LABEL = enMessages.agent.askComfyAgent
|
||||
|
||||
function pushEvent(ws: WebSocketRoute, event: AgentWsEvent): void {
|
||||
ws.send(JSON.stringify(event))
|
||||
}
|
||||
|
||||
test.describe('In-App Agent panel', { tag: '@cloud' }, () => {
|
||||
test.describe('flag off', () => {
|
||||
test.use({ agentFlagEnabled: false })
|
||||
|
||||
test('does not expose the Ask Comfy Agent button', async ({
|
||||
comfyPage,
|
||||
postedMessages
|
||||
}) => {
|
||||
expect(postedMessages).toHaveLength(0)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('shows the greeting, inserts a suggested prompt, and completes a chat turn', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
|
||||
const page = comfyPage.page
|
||||
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
await expect(panel.getByText(/^Hello/)).toBeVisible()
|
||||
await expect(panel.getByText('What do you want to make?')).toBeVisible()
|
||||
const firstPrompt = enMessages.agent.suggestedPrompts[0]
|
||||
const promptChip = panel.getByRole('button', { name: firstPrompt })
|
||||
await expect(promptChip).toBeVisible()
|
||||
|
||||
const composer = panel.getByPlaceholder(enMessages.agent.placeholder)
|
||||
const sendButton = panel.getByRole('button', { name: 'Send' })
|
||||
|
||||
await expect(composer).toHaveValue('')
|
||||
await promptChip.click()
|
||||
await expect(composer).toHaveValue(firstPrompt)
|
||||
expect(
|
||||
postedMessages,
|
||||
'inserting a prompt must not POST a message'
|
||||
).toHaveLength(0)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
await sendButton.click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
expect(postedMessages[0]).toContain(firstPrompt)
|
||||
await expect(composer).toHaveValue('')
|
||||
|
||||
pushEvent(ws, THINKING_EVENT)
|
||||
await expect(panel.getByText('Thinking...')).toBeVisible()
|
||||
|
||||
pushEvent(ws, TOOL_CALL_EVENT)
|
||||
await expect(panel.getByText('Ran 1 tool call')).toBeVisible()
|
||||
|
||||
pushEvent(ws, MESSAGE_DELTA_EVENT)
|
||||
await expect(
|
||||
panel.locator('strong', { hasText: 'fully ready' })
|
||||
).toBeVisible()
|
||||
await expect(panel.getByText('Thinking...')).toBeHidden()
|
||||
|
||||
pushEvent(ws, MESSAGE_DONE_EVENT)
|
||||
await expect(panel.getByRole('button', { name: 'Send' })).toBeVisible()
|
||||
await expect(panel.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('applies a draft_patch graph to the canvas', async ({
|
||||
comfyPage,
|
||||
postedMessages,
|
||||
getWebSocket
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
|
||||
const page = comfyPage.page
|
||||
const panel = page.locator('#agent-panel-root')
|
||||
|
||||
const openButton = page.getByRole('button', { name: OPEN_AGENT_LABEL })
|
||||
await expect(openButton).toBeVisible()
|
||||
await openButton.click()
|
||||
await expect(panel).toBeVisible()
|
||||
|
||||
await panel.getByPlaceholder(enMessages.agent.placeholder).fill('Build it')
|
||||
await panel.getByRole('button', { name: 'Send' }).click()
|
||||
await expect.poll(() => postedMessages.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const ws = await getWebSocket()
|
||||
pushEvent(ws, { type: 'draft_patch', data: DRAFT_PATCH })
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.app!.graph!.nodes.length))
|
||||
.toBe(2)
|
||||
const nodeTypes = await page.evaluate(() =>
|
||||
window.app!.graph!.nodes.map((n) => n.type)
|
||||
)
|
||||
expect(nodeTypes).toEqual(
|
||||
expect.arrayContaining(['CheckpointLoaderSimple', 'SaveImage'])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,229 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
AgentDraftSnapshot,
|
||||
AgentTurnAccepted,
|
||||
AgentWsEvent,
|
||||
DraftPatchData
|
||||
} from '@/workbench/extensions/agent/schemas/agentApiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const THREAD_ID = 'd4c016c4-3b8c-44cf-97de-1ae27e43e718'
|
||||
const TURN_ID = '3818ba00-d772-4a3f-98c1-9312725b577d'
|
||||
const WORKFLOW_ID = 'a81718a4-02ae-41e6-ae85-c33b7bb880f6'
|
||||
|
||||
const TURN_ACCEPTED: AgentTurnAccepted = {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
workflow_id: WORKFLOW_ID
|
||||
}
|
||||
|
||||
const DRAFT_GRAPH: ComfyWorkflowJSON = {
|
||||
version: 0.4,
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [100, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'MODEL', type: 'MODEL', links: [] },
|
||||
{ name: 'CLIP', type: 'CLIP', links: [] },
|
||||
{ name: 'VAE', type: 'VAE', links: [] }
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ['sd_xl_base_1.0.safetensors']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'SaveImage',
|
||||
pos: [1400, 300],
|
||||
size: [210, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: null }],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
widgets_values: ['ComfyUI']
|
||||
}
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
const DRAFT_SNAPSHOT: AgentDraftSnapshot = {
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
version: 24
|
||||
}
|
||||
|
||||
export const DRAFT_PATCH: DraftPatchData = {
|
||||
base_version: 24,
|
||||
version: 25,
|
||||
content: DRAFT_GRAPH as unknown as Record<string, unknown>,
|
||||
workflow_id: WORKFLOW_ID,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
|
||||
export const THINKING_EVENT: AgentWsEvent = {
|
||||
type: 'agent_thinking',
|
||||
data: {
|
||||
delta: "I'll set the positive prompt to your red fox scene.",
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const TOOL_CALL_EVENT: AgentWsEvent = {
|
||||
type: 'agent_tool_call',
|
||||
data: {
|
||||
tool_name: 'set_widget',
|
||||
status: 'ok',
|
||||
args: ['workflow', 'set-widget', 'workflow.json'],
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_DELTA_TEXT =
|
||||
'The graph is **fully ready** to go — prompt set to the red fox in the snow.'
|
||||
|
||||
export const MESSAGE_DELTA_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_delta',
|
||||
data: {
|
||||
delta: MESSAGE_DELTA_TEXT,
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const MESSAGE_DONE_EVENT: AgentWsEvent = {
|
||||
type: 'agent_message_done',
|
||||
data: {
|
||||
message_id: TURN_ID,
|
||||
thread_id: THREAD_ID,
|
||||
usage: {
|
||||
input_tokens: 4493,
|
||||
output_tokens: 425,
|
||||
total_tokens: 12393,
|
||||
cache_read_input_tokens: 35596,
|
||||
cache_creation_input_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function agentFeatures(agentFlag: boolean): RemoteConfig {
|
||||
return {
|
||||
team_workspaces_enabled: true,
|
||||
posthog_project_token: 'phc_e2e_agent_panel',
|
||||
posthog_config: {
|
||||
advanced_disable_flags: true,
|
||||
bootstrap: {
|
||||
featureFlags: { 'agent-in-app-experience': agentFlag }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockAgentBoot(
|
||||
page: Page,
|
||||
{
|
||||
agentFlag,
|
||||
postedMessages
|
||||
}: { agentFlag: boolean; postedMessages: string[] }
|
||||
): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.AgentPanel.onboarded', 'true')
|
||||
})
|
||||
|
||||
await mockBilling(page)
|
||||
await page.route('**/api/assets**', (r) =>
|
||||
r.fulfill(jsonRoute({ assets: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(agentFeatures(agentFlag)))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: '2100-01-01T00:00:00.000Z',
|
||||
workspace: { id: 'ws-personal', name: 'Personal', type: 'personal' },
|
||||
role: 'owner',
|
||||
permissions: ['owner:*']
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await page.route('**/api/agent/threads/*/messages', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'POST') {
|
||||
postedMessages.push(request.postData() ?? '')
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(TURN_ACCEPTED)
|
||||
})
|
||||
}
|
||||
return route.fulfill(jsonRoute([]))
|
||||
})
|
||||
|
||||
await page.route('**/api/agent/draft**', (r) =>
|
||||
r.fulfill(jsonRoute(DRAFT_SNAPSHOT))
|
||||
)
|
||||
}
|
||||
@@ -205,30 +205,27 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
await expect(content.getByText('50% used')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Used after plan credits run out')
|
||||
).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Used after plan credits run out')
|
||||
).toBeHidden()
|
||||
await expect(content.getByText('50% used')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
@@ -240,27 +237,17 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('100% used')).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('2,110', { exact: true }).last()
|
||||
).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('0', { exact: true }).first()).toBeVisible()
|
||||
await expect(content.getByText('100% used')).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
|
||||
@@ -22,7 +22,7 @@ import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMo
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* "Change role ›" (Admin / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
@@ -44,13 +44,15 @@ async function openMembersTab(page: Page): Promise<Locator> {
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
await expect(
|
||||
content.getByRole('tabpanel', { name: 'Members (4)' }).getByRole('table')
|
||||
).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.getByRole('row')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ async function openChangeRoleSubmenu(page: Page) {
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
@@ -111,14 +113,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
.press('Enter')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
await expect(page.getByRole('heading', { name: /an admin\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
@@ -134,11 +136,11 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
.getByRole('menuitemradio', { name: 'Admin', exact: true })
|
||||
.press('Enter')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
page.getByRole('heading', { name: 'Make Jane an admin?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
@@ -147,7 +149,7 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
'Promote and demote other admins (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
@@ -177,12 +179,12 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
.getByRole('menuitemradio', { name: 'Admin', exact: true })
|
||||
.press('Enter')
|
||||
await page.getByRole('button', { name: 'Make admin' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
@@ -211,13 +213,13 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
.press('Enter')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
@@ -249,14 +251,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
.getByRole('menuitemradio', { name: 'Admin', exact: true })
|
||||
.press('Enter')
|
||||
await page.getByRole('button', { name: 'Make admin' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
page.getByRole('heading', { name: 'Make Jane an admin?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const SLOT_ERROR_CLASS = /before:ring-error/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function getInputSlotIndexByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
inputName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
({ inputName, nodeId }) => {
|
||||
const graph = window.app!.canvas.graph ?? window.app!.graph
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const index = node?.findInputSlot(inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ inputName, nodeId: toNodeId(nodeId) }
|
||||
)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
const modelInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
ksamplerId,
|
||||
KSAMPLER_MODEL_INPUT_NAME
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('boundary-linked validation error surfaces on the subgraph host', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
const hostInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
subgraphParentId,
|
||||
'positive'
|
||||
)
|
||||
const hostInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
subgraphParentId,
|
||||
hostInputIndex
|
||||
)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host must mount before injecting validation errors'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host should start without an error ring'
|
||||
).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
await test.step('surface the boundary-linked error on the host', async () => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
'positive',
|
||||
'Required input is missing: positive'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('confirm the interior node does not show the surfaced ring', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
const interiorKSamplerId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const interiorPositiveInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
interiorKSamplerId,
|
||||
'positive'
|
||||
)
|
||||
const interiorPositiveSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
interiorKSamplerId,
|
||||
interiorPositiveInputIndex
|
||||
)
|
||||
const interiorInnerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
|
||||
|
||||
await expect(interiorInnerWrapper).toBeVisible()
|
||||
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
await expect(interiorPositiveSlotHighlight).toBeVisible()
|
||||
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
|
||||
SLOT_ERROR_CLASS
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -477,4 +478,49 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@@ -167,7 +167,7 @@ catalogs:
|
||||
version: 2.0.1
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.7
|
||||
version: 6.0.3
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16
|
||||
@@ -380,7 +380,7 @@ catalogs:
|
||||
version: 3.2.2
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.2
|
||||
version: 8.0.5
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.8
|
||||
@@ -389,7 +389,7 @@ catalogs:
|
||||
version: 3.5.34
|
||||
vue-component-type-helpers:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.7
|
||||
version: 3.3.2
|
||||
vue-eslint-parser:
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0
|
||||
@@ -717,7 +717,7 @@ importers:
|
||||
version: 0.184.1
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(vitest@4.1.8)
|
||||
@@ -861,13 +861,13 @@ importers:
|
||||
version: 3.2.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.7
|
||||
version: 3.3.2
|
||||
vue-eslint-parser:
|
||||
specifier: 'catalog:'
|
||||
version: 10.4.0(eslint@10.4.0(jiti@2.6.1))
|
||||
@@ -925,7 +925,7 @@ importers:
|
||||
version: 4.3.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
dotenv:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.1
|
||||
@@ -943,7 +943,7 @@ importers:
|
||||
version: 3.2.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: 'catalog:'
|
||||
version: 8.1.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
version: 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
vue-tsc:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.5(typescript@5.9.3)
|
||||
@@ -1037,7 +1037,7 @@ importers:
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue-component-type-helpers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.7
|
||||
version: 3.3.2
|
||||
|
||||
packages/comfyui-desktop-bridge-types: {}
|
||||
|
||||
@@ -3305,6 +3305,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
@@ -3464,32 +3467,32 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@4.3.1':
|
||||
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
|
||||
'@shikijs/core@4.1.0':
|
||||
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.3.1':
|
||||
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
|
||||
'@shikijs/langs@4.1.0':
|
||||
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.3.1':
|
||||
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
|
||||
'@shikijs/primitive@4.1.0':
|
||||
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.3.1':
|
||||
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
|
||||
'@shikijs/themes@4.1.0':
|
||||
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.3.1':
|
||||
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
|
||||
'@shikijs/types@4.1.0':
|
||||
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
@@ -4203,6 +4206,13 @@ packages:
|
||||
vite: ^8.0.13
|
||||
vue: ^3.0.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3':
|
||||
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7':
|
||||
resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -4359,6 +4369,11 @@ packages:
|
||||
'@vue/devtools-api@7.7.9':
|
||||
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
|
||||
|
||||
'@vue/devtools-core@8.0.5':
|
||||
resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
'@vue/devtools-core@8.1.2':
|
||||
resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==}
|
||||
peerDependencies:
|
||||
@@ -4367,12 +4382,18 @@ packages:
|
||||
'@vue/devtools-kit@7.7.9':
|
||||
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
|
||||
|
||||
'@vue/devtools-kit@8.0.5':
|
||||
resolution: {integrity: sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==}
|
||||
|
||||
'@vue/devtools-kit@8.1.2':
|
||||
resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==}
|
||||
|
||||
'@vue/devtools-shared@7.7.9':
|
||||
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
|
||||
|
||||
'@vue/devtools-shared@8.0.5':
|
||||
resolution: {integrity: sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==}
|
||||
|
||||
'@vue/devtools-shared@8.1.2':
|
||||
resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==}
|
||||
|
||||
@@ -6688,6 +6709,10 @@ packages:
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lru-cache@11.2.6:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@11.5.1:
|
||||
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -6721,6 +6746,9 @@ packages:
|
||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
magicast@0.5.3:
|
||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||
|
||||
@@ -7044,6 +7072,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.5:
|
||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.3:
|
||||
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -7894,8 +7927,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@4.3.1:
|
||||
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
|
||||
shiki@4.1.0:
|
||||
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
@@ -8220,6 +8253,10 @@ packages:
|
||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyrainbow@3.0.3:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -8632,12 +8669,23 @@ packages:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5:
|
||||
resolution: {integrity: sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==}
|
||||
engines: {node: '>=v14.21.3'}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-devtools@8.1.2:
|
||||
resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==}
|
||||
engines: {node: '>=v14.21.3'}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2:
|
||||
resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
|
||||
peerDependencies:
|
||||
vite: ^8.0.13
|
||||
|
||||
vite-plugin-vue-inspector@6.0.0:
|
||||
resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
|
||||
peerDependencies:
|
||||
@@ -8842,8 +8890,11 @@ packages:
|
||||
vue-component-type-helpers@2.2.12:
|
||||
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
|
||||
|
||||
vue-component-type-helpers@3.3.7:
|
||||
resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==}
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -9289,7 +9340,7 @@ snapshots:
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 11.5.1
|
||||
lru-cache: 11.2.6
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.6':
|
||||
dependencies:
|
||||
@@ -9297,7 +9348,7 @@ snapshots:
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.1.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.1
|
||||
lru-cache: 11.2.6
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
@@ -9323,7 +9374,7 @@ snapshots:
|
||||
js-yaml: 4.1.1
|
||||
picomatch: 4.0.4
|
||||
retext-smartypants: 6.2.0
|
||||
shiki: 4.3.1
|
||||
shiki: 4.1.0
|
||||
smol-toml: 1.6.1
|
||||
unified: 11.0.5
|
||||
|
||||
@@ -11311,6 +11362,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rollup/pluginutils@4.2.1':
|
||||
@@ -11484,40 +11537,40 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
|
||||
'@shikijs/core@4.3.1':
|
||||
'@shikijs/core@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/primitive': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.3.1':
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.3.1':
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.3.1':
|
||||
'@shikijs/langs@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/types': 4.1.0
|
||||
|
||||
'@shikijs/primitive@4.3.1':
|
||||
'@shikijs/primitive@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@4.3.1':
|
||||
'@shikijs/themes@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/types': 4.1.0
|
||||
|
||||
'@shikijs/types@4.3.1':
|
||||
'@shikijs/types@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
@@ -11624,7 +11677,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.7
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -12250,12 +12303,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.7(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -12271,10 +12330,10 @@ snapshots:
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.3
|
||||
magicast: 0.5.1
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.1.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12318,7 +12377,7 @@ snapshots:
|
||||
|
||||
'@vitest/pretty-format@4.0.16':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
@@ -12350,7 +12409,7 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/coverage-v8@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
@@ -12362,7 +12421,7 @@ snapshots:
|
||||
'@vitest/utils@4.0.16':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
tinyrainbow: 3.1.0
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
@@ -12531,6 +12590,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.9
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
mitt: 3.0.1
|
||||
nanoid: 5.1.5
|
||||
pathe: 2.0.3
|
||||
vite-hot-client: 2.1.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@vue/devtools-core@8.1.2(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 8.1.2
|
||||
@@ -12547,6 +12630,16 @@ snapshots:
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.2
|
||||
|
||||
'@vue/devtools-kit@8.0.5':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 2.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.2
|
||||
|
||||
'@vue/devtools-kit@8.1.2':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 8.1.2
|
||||
@@ -12558,6 +12651,10 @@ snapshots:
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/devtools-shared@8.0.5':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/devtools-shared@8.1.2': {}
|
||||
|
||||
'@vue/language-core@2.2.0(typescript@5.9.3)':
|
||||
@@ -12891,7 +12988,7 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 4.3.1
|
||||
shiki: 4.1.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyclip: 0.1.13
|
||||
@@ -15132,6 +15229,8 @@ snapshots:
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lru-cache@11.5.1: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
@@ -15160,6 +15259,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
magicast@0.5.3:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -15768,6 +15873,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
|
||||
napi-postinstall@0.3.3: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -16120,7 +16227,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.5.1
|
||||
lru-cache: 11.2.6
|
||||
minipass: 7.1.3
|
||||
|
||||
path-type@4.0.0: {}
|
||||
@@ -16900,14 +17007,14 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@4.3.1:
|
||||
shiki@4.1.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.3.1
|
||||
'@shikijs/engine-javascript': 4.3.1
|
||||
'@shikijs/engine-oniguruma': 4.3.1
|
||||
'@shikijs/langs': 4.3.1
|
||||
'@shikijs/themes': 4.3.1
|
||||
'@shikijs/types': 4.3.1
|
||||
'@shikijs/core': 4.1.0
|
||||
'@shikijs/engine-javascript': 4.1.0
|
||||
'@shikijs/engine-oniguruma': 4.1.0
|
||||
'@shikijs/langs': 4.1.0
|
||||
'@shikijs/themes': 4.1.0
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
@@ -17273,6 +17380,8 @@ snapshots:
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
@@ -17741,15 +17850,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-devtools@8.1.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.1.2(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.1.2
|
||||
'@vue/devtools-shared': 8.1.2
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-devtools@8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-core': 8.0.5(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue/devtools-kit': 8.0.5
|
||||
'@vue/devtools-shared': 8.0.5
|
||||
sirv: 3.0.2
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
vite-plugin-inspect: 11.3.3(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
vite-plugin-vue-inspector: 5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- supports-color
|
||||
@@ -17769,7 +17892,7 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
@@ -17784,6 +17907,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@5.3.2(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
|
||||
'@vue/compiler-dom': 3.5.34
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.21
|
||||
vite: 8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite-plugin-vue-inspector@6.0.0(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -18009,7 +18147,9 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.12: {}
|
||||
|
||||
vue-component-type-helpers@3.3.7: {}
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
@import '../../workbench/extensions/agent/agentTheme.css';
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
|
||||
@@ -1,154 +1,133 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="agentPanelDocked"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isAgentResizing"
|
||||
@pointerdown="onAgentResizeStart"
|
||||
@lostpointercapture="isAgentResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -163,13 +142,11 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -185,33 +162,6 @@ const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const {
|
||||
isOpen: agentPanelOpen,
|
||||
enabled: agentPanelEnabled,
|
||||
width: agentPanelWidth
|
||||
} = storeToRefs(agentPanelStore)
|
||||
const agentPanelDocked = computed(
|
||||
() => agentPanelEnabled.value && agentPanelOpen.value
|
||||
)
|
||||
const isAgentResizing = ref(false)
|
||||
let agentResizeStartX = 0
|
||||
let agentResizeStartWidth = 0
|
||||
|
||||
function onAgentResizeStart(e: PointerEvent): void {
|
||||
isAgentResizing.value = true
|
||||
agentResizeStartX = e.clientX
|
||||
agentResizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isAgentResizing.value) return
|
||||
agentPanelStore.setWidth(
|
||||
agentResizeStartWidth + (agentResizeStartX - e.clientX)
|
||||
)
|
||||
})
|
||||
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
@@ -354,10 +304,4 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover,
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,14 @@ import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
TaskItemImpl,
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
@@ -62,7 +64,8 @@ const i18n = createI18n({
|
||||
stopRunInstantTooltip: 'Stop running',
|
||||
runWorkflow: 'Run workflow',
|
||||
runWorkflowFront: 'Run workflow front',
|
||||
runWorkflowDisabled: 'Run workflow disabled'
|
||||
runWorkflowDisabled: 'Run workflow disabled',
|
||||
runWorkflowDisabledNodes: 'Run workflow disabled nodes'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,4 +185,32 @@ describe('ComfyQueueButton', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps instant mode idle while dispatching a disabled-node queue command', async () => {
|
||||
const { user } = renderQueueButton()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const commandStore = useCommandStore()
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
disabledPartnerNodesStore.offenders = [
|
||||
{
|
||||
nodeId: createNodeExecutionId([1]),
|
||||
displayName: 'Blocked Partner Node'
|
||||
}
|
||||
]
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByTestId('queue-button'))
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
expect(disabledPartnerNodesStore.scanGraph).toHaveBeenCalledOnce()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,6 +82,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
isInstantMode,
|
||||
@@ -99,6 +100,10 @@ const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
const hasDisabledNodes = computed(
|
||||
() => disabledPartnerNodesStore.offenders.length > 0
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
|
||||
@@ -190,7 +195,7 @@ const iconClass = computed(() => {
|
||||
if (isStopInstantAction.value) {
|
||||
return 'icon-[lucide--square]'
|
||||
}
|
||||
if (hasMissingNodes.value) {
|
||||
if (hasMissingNodes.value || hasDisabledNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
@@ -215,6 +220,9 @@ const queueButtonTooltip = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
if (hasDisabledNodes.value) {
|
||||
return t('menu.runWorkflowDisabledNodes')
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return t('menu.runWorkflowFront')
|
||||
}
|
||||
@@ -233,7 +241,8 @@ const queuePrompt = async (e: Event) => {
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isInstantMode(queueMode.value)) {
|
||||
disabledPartnerNodesStore.scanGraph()
|
||||
if (!hasDisabledNodes.value && isInstantMode(queueMode.value)) {
|
||||
queueMode.value = 'instant-running'
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
|
||||
@@ -19,7 +19,11 @@ defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +31,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -48,7 +53,7 @@ const contentStyle = useModalLiftedZIndex(open)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
43
src/components/common/SelectionBar.vue
Normal file
43
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -404,6 +404,18 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('allows dismiss when target is an outside popup trigger', () => {
|
||||
const trigger = document.createElement('button')
|
||||
trigger.setAttribute('aria-haspopup', 'menu')
|
||||
document.body.appendChild(trigger)
|
||||
|
||||
const event = makeEvent(trigger)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
trigger.remove()
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -38,15 +38,6 @@
|
||||
<AppBuilder v-if="isBuilderMode" />
|
||||
<NodePropertiesPanel v-else />
|
||||
</template>
|
||||
<template #agent-panel>
|
||||
<div class="size-full p-2">
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
|
||||
>
|
||||
<AgentPanelRoot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
@@ -120,7 +111,6 @@
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
@@ -207,10 +197,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const AgentPanelRoot = defineAsyncComponent(
|
||||
() => import('@/workbench/extensions/agent/AgentPanelRoot.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -157,14 +158,33 @@ const hasMissingMediaSelected = computed(
|
||||
)
|
||||
)
|
||||
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
const activeDisabledGraphNodeIds = computed<Set<string>>(() => {
|
||||
if (!app.isGraphReady) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
disabledPartnerNodesStore.disabledAncestorExecutionIds
|
||||
)
|
||||
})
|
||||
const hasDisabledNodeSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeDisabledGraphNodeIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
if (!hasSelection.value)
|
||||
return hasAnyError.value || disabledPartnerNodesStore.offenders.length > 0
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value ||
|
||||
hasMissingMediaSelected.value
|
||||
hasMissingMediaSelected.value ||
|
||||
hasDisabledNodeSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -309,6 +309,11 @@
|
||||
:highlighted-node-ids="selectionMatchedAssetNodeIds"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
<DisabledNodesCard
|
||||
v-if="group.type === 'disabled_node'"
|
||||
:offenders="disabledPartnerNodesStore.offenders"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
/>
|
||||
</ErrorCardSection>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -336,10 +341,12 @@ 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 DisabledNodesCard from '@/platform/workspace/components/errors/DisabledNodesCard.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
@@ -362,6 +369,7 @@ const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode } = useFocusNode()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
useManagerState()
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
@@ -150,22 +150,6 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes raw details through to the card for agent prompt errors only', () => {
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'agent_api_failed',
|
||||
message: 'Comfy Agent hit a server error.',
|
||||
details: 'HTTP 500 from /api/agent/threads'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText('HTTP 500 from /api/agent/threads')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
|
||||
@@ -45,3 +45,6 @@ export type ErrorGroup =
|
||||
| (ErrorGroupBase & {
|
||||
type: 'missing_media'
|
||||
})
|
||||
| (ErrorGroupBase & {
|
||||
type: 'disabled_node'
|
||||
})
|
||||
|
||||
@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
@@ -63,10 +65,6 @@ vi.mock('@/i18n', () => {
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.title':
|
||||
'An error was found',
|
||||
'errorCatalog.promptErrors.agent_draft_apply_failed.desc':
|
||||
"Couldn't apply the agent's draft to the canvas.",
|
||||
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
|
||||
'errorCatalog.runtimeErrors.execution_failed.message':
|
||||
'Node threw an error during execution.',
|
||||
@@ -130,7 +128,10 @@ vi.mock(
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
@@ -329,6 +330,39 @@ describe('useErrorGroups', () => {
|
||||
expect(groups.allErrorGroups.value).toEqual([])
|
||||
})
|
||||
|
||||
it('includes disabled nodes in the group and node summary', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_graph, nodeId) =>
|
||||
fromAny<LGraphNode, unknown>({ id: nodeId })
|
||||
)
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '7' }])
|
||||
useDisabledPartnerNodesStore().offenders = [
|
||||
{
|
||||
nodeId: fromAny<NodeExecutionId, unknown>('7'),
|
||||
displayName: 'Selected disabled partner node'
|
||||
},
|
||||
{
|
||||
nodeId: fromAny<NodeExecutionId, unknown>('8'),
|
||||
displayName: 'Other disabled partner node'
|
||||
}
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(groups.allErrorGroups.value).toEqual([
|
||||
expect.objectContaining({ type: 'disabled_node', count: 2 })
|
||||
])
|
||||
expect(groups.errorNodeCount.value).toBe(2)
|
||||
expect(groups.selectionMatchedGroupKeys.value).toEqual(
|
||||
new Set(['disabled_node'])
|
||||
)
|
||||
expect(groups.selectionErrorCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('includes missing_node group when missing nodes exist', async () => {
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
@@ -497,6 +531,47 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups lifted boundary errors under the host node card', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph({
|
||||
interiorType: 'InteriorClass'
|
||||
})
|
||||
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
|
||||
await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError(
|
||||
[
|
||||
validationError(
|
||||
'required_input_missing',
|
||||
'seed_input',
|
||||
{},
|
||||
'Required input is missing'
|
||||
)
|
||||
],
|
||||
'InteriorClass'
|
||||
)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const card = execGroup.cards[0]
|
||||
expect(card.nodeId).toBe('12')
|
||||
expect(card.title).toBe(host.title)
|
||||
expect(card.errors[0].displayDetails).toBe(
|
||||
`${host.title} is missing a required input: seed`
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
@@ -633,25 +708,6 @@ describe('useErrorGroups', () => {
|
||||
expect(promptGroup).toBeDefined()
|
||||
})
|
||||
|
||||
it('carries prompt error details onto the card item', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastPromptError = {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const promptGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution' && g.displayTitle === 'An error was found'
|
||||
)
|
||||
const details =
|
||||
promptGroup && 'cards' in promptGroup
|
||||
? promptGroup.cards[0]?.errors[0]?.details
|
||||
: undefined
|
||||
expect(details).toBe('Validation error: Required at "version"')
|
||||
})
|
||||
|
||||
it('includes prompt error when a node is selected', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { st } from '@/i18n'
|
||||
import { st, t } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import { shouldRenderExecutionItemList } from './executionItemList'
|
||||
@@ -46,11 +47,6 @@ import {
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
|
||||
const AGENT_PROMPT_ERROR_TYPES = new Set([
|
||||
'agent_api_failed',
|
||||
'agent_draft_apply_failed'
|
||||
])
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
@@ -241,6 +237,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -377,9 +374,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
errors: [
|
||||
{
|
||||
message: error.message,
|
||||
...(AGENT_PROMPT_ERROR_TYPES.has(error.type)
|
||||
? { details: error.details }
|
||||
: {}),
|
||||
...resolvedDisplay
|
||||
}
|
||||
]
|
||||
@@ -390,10 +384,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
if (!executionErrorStore.surfacedNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
executionErrorStore.surfacedNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
@@ -655,6 +649,31 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
const filteredDisabledNodes = computed(() => {
|
||||
const all = disabledPartnerNodesStore.offenders
|
||||
if (!selectedNodeInfo.value.nodeIds) return all
|
||||
return all.filter((offender) => isAssetErrorInSelection(offender.nodeId))
|
||||
})
|
||||
|
||||
function buildDisabledNodeGroups(
|
||||
offenders: typeof disabledPartnerNodesStore.offenders
|
||||
): ErrorGroup[] {
|
||||
if (!offenders.length) return []
|
||||
return [
|
||||
{
|
||||
type: 'disabled_node' as const,
|
||||
groupKey: 'disabled_node',
|
||||
count: offenders.length,
|
||||
priority: 0,
|
||||
displayTitle: t('rightSidePanel.disabledNodes.title', offenders.length),
|
||||
displayMessage: t(
|
||||
'rightSidePanel.disabledNodes.message',
|
||||
offenders.length
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
return groupMissingModelCandidates(
|
||||
missingModelStore.missingModelCandidates,
|
||||
@@ -805,6 +824,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return [
|
||||
...buildDisabledNodeGroups(disabledPartnerNodesStore.offenders),
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
@@ -827,6 +847,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return [
|
||||
...buildDisabledNodeGroups(filteredDisabledNodes.value),
|
||||
...buildMissingNodeGroups((nodeTypes) =>
|
||||
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
|
||||
),
|
||||
@@ -894,7 +915,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
|
||||
.map((card) => card.nodeId)
|
||||
.filter((nodeId) => nodeId != null)
|
||||
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
|
||||
const disabledNodeIds = disabledPartnerNodesStore.offenders.map(
|
||||
(offender) => offender.nodeId
|
||||
)
|
||||
return new Set([
|
||||
...executionNodeIds,
|
||||
...assetNodeIdsWithError.value,
|
||||
...disabledNodeIds
|
||||
]).size
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
|
||||
@@ -2,6 +2,16 @@ import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockIsNodeDefDisabled = vi.hoisted(() =>
|
||||
vi.fn<(nodeDef: ComfyNodeDefImpl) => boolean>(() => false)
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/disabledPartnerNodesStore', () => ({
|
||||
useDisabledPartnerNodesStore: () => ({
|
||||
isNodeDefDisabled: mockIsNodeDefDisabled
|
||||
})
|
||||
}))
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
@@ -24,6 +34,8 @@ describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
mockIsNodeDefDisabled.mockReset()
|
||||
mockIsNodeDefDisabled.mockReturnValue(false)
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
@@ -315,6 +327,93 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
describe('search and category interaction', () => {
|
||||
it('does not report disabled matches in the default empty state', async () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
nodeDefStore.updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'BlockedPartnerNode',
|
||||
display_name: 'Blocked Partner Node',
|
||||
api_node: true
|
||||
})
|
||||
])
|
||||
mockIsNodeDefDisabled.mockReturnValue(true)
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'test.disabled-partner-nodes',
|
||||
name: 'Disabled partner nodes',
|
||||
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(await screen.findByText('No Results')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('This node has been disabled by your team admin.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains when a query only matches an admin-disabled node', async () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
nodeDefStore.updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'BlockedPartnerNode',
|
||||
display_name: 'Blocked Partner Node',
|
||||
api_node: true
|
||||
})
|
||||
])
|
||||
mockIsNodeDefDisabled.mockImplementation(
|
||||
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
|
||||
)
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'test.disabled-partner-nodes',
|
||||
name: 'Disabled partner nodes',
|
||||
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
|
||||
})
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'This node has been disabled by your team admin.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not report a disabled match outside the selected category', async () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
nodeDefStore.updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'BlockedPartnerNode',
|
||||
display_name: 'Blocked Partner Node',
|
||||
category: 'loaders',
|
||||
api_node: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'SamplerNode',
|
||||
display_name: 'Sampler Node',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
mockIsNodeDefDisabled.mockImplementation(
|
||||
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
|
||||
)
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'test.disabled-partner-nodes',
|
||||
name: 'Disabled partner nodes',
|
||||
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
|
||||
})
|
||||
const { user } = renderComponent()
|
||||
await user.click(await screen.findByTestId('category-sampling'))
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
|
||||
|
||||
expect(await screen.findByText('No Results')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('This node has been disabled by your team admin.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should search within selected category', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
@@ -757,6 +856,82 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('counts disabled nodes only in the selected category without a query', async () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDefs = [
|
||||
createMockNodeDef({
|
||||
name: 'CustomSampler',
|
||||
display_name: 'Custom Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomLoader',
|
||||
display_name: 'Custom Loader',
|
||||
category: 'loaders',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
]
|
||||
nodeDefStore.updateNodeDefs(nodeDefs)
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await user.click(await screen.findByTestId('category-custom/sampling'))
|
||||
|
||||
mockIsNodeDefDisabled.mockReturnValue(true)
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'test.disabled-partner-nodes',
|
||||
name: 'Disabled partner nodes',
|
||||
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
|
||||
})
|
||||
nodeDefStore.updateNodeDefs(nodeDefs)
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'This node has been disabled by your team admin.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores disabled matches outside the selected category', async () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeDefs = [
|
||||
createMockNodeDef({
|
||||
name: 'CustomSampler',
|
||||
display_name: 'Custom Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomLoader',
|
||||
display_name: 'Custom Loader',
|
||||
category: 'loaders',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
]
|
||||
nodeDefStore.updateNodeDefs(nodeDefs)
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await user.click(await screen.findByTestId('category-custom/sampling'))
|
||||
|
||||
mockIsNodeDefDisabled.mockImplementation(
|
||||
(nodeDef) => nodeDef.name === 'CustomLoader'
|
||||
)
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'test.disabled-partner-nodes',
|
||||
name: 'Disabled partner nodes',
|
||||
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
|
||||
})
|
||||
nodeDefStore.updateNodeDefs(nodeDefs)
|
||||
await user.type(screen.getByRole('combobox'), 'Loader')
|
||||
|
||||
expect(await screen.findByText('No Results')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('This node has been disabled by your team admin.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
|
||||
@@ -99,7 +99,11 @@
|
||||
data-testid="no-results"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
{{
|
||||
disabledMatchCount > 0
|
||||
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
|
||||
: $t('g.noResults')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,11 +125,12 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
BLUEPRINT_CATEGORY,
|
||||
isCustomNode,
|
||||
@@ -158,6 +163,7 @@ const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
|
||||
|
||||
const nodeAvailability = computed(() => {
|
||||
let essential = false
|
||||
@@ -216,21 +222,28 @@ const rootFilterLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function rootFilterPredicate(
|
||||
root: RootCategoryId
|
||||
): (n: ComfyNodeDefImpl) => boolean {
|
||||
const sourceFilter = sourceCategoryFilters[root]
|
||||
if (sourceFilter) return sourceFilter
|
||||
switch (root) {
|
||||
case RootCategory.Favorites:
|
||||
return (n) => nodeBookmarkStore.isBookmarked(n)
|
||||
case RootCategory.Blueprint:
|
||||
return (n) => n.category.startsWith(BLUEPRINT_CATEGORY)
|
||||
case RootCategory.PartnerNodes:
|
||||
return (n) => n.api_node
|
||||
default:
|
||||
return () => true
|
||||
}
|
||||
}
|
||||
|
||||
const rootFilteredNodeDefs = computed(() => {
|
||||
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case RootCategory.Favorites:
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case RootCategory.Blueprint:
|
||||
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
case RootCategory.PartnerNodes:
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
default:
|
||||
return allNodes
|
||||
}
|
||||
return nodeDefStore.visibleNodeDefs.filter(
|
||||
rootFilterPredicate(rootFilter.value)
|
||||
)
|
||||
})
|
||||
|
||||
function onToggleFilter(
|
||||
@@ -311,6 +324,15 @@ function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function filterBySelectedCategory(baseNodes: ComfyNodeDefImpl[]) {
|
||||
const category = selectedCategory.value
|
||||
if (category === DEFAULT_CATEGORY) return baseNodes
|
||||
const sourceFilter = sourceCategoryFilters[category]
|
||||
return sourceFilter
|
||||
? baseNodes.filter(sourceFilter)
|
||||
: getCategoryResults(baseNodes, category)
|
||||
}
|
||||
|
||||
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const baseNodes = rootFilteredNodeDefs.value
|
||||
const category = selectedCategory.value
|
||||
@@ -330,10 +352,31 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
} else {
|
||||
source = baseNodes
|
||||
}
|
||||
return filterBySelectedCategory(source)
|
||||
})
|
||||
|
||||
const sourceFilter = sourceCategoryFilters[category]
|
||||
if (sourceFilter) return source.filter(sourceFilter)
|
||||
return getCategoryResults(source, category)
|
||||
const disabledMatchCount = computed(() => {
|
||||
if (displayedResults.value.length > 0) return 0
|
||||
if (disabledNodeDefs.value.length === 0) return 0
|
||||
const inRoot = rootFilter.value
|
||||
? disabledNodeDefs.value.filter(rootFilterPredicate(rootFilter.value))
|
||||
: disabledNodeDefs.value
|
||||
if (!searchQuery.value && filters.length === 0) {
|
||||
if (!rootFilter.value && selectedCategory.value === DEFAULT_CATEGORY) {
|
||||
return 0
|
||||
}
|
||||
return filterBySelectedCategory(inRoot).length
|
||||
}
|
||||
const matched = disabledSearchService.value.searchNode(
|
||||
searchQuery.value,
|
||||
filters,
|
||||
{ limit: 64 }
|
||||
)
|
||||
if (!rootFilter.value) return filterBySelectedCategory(matched).length
|
||||
const inRootNames = new Set(inRoot.map((n) => n.name))
|
||||
return filterBySelectedCategory(
|
||||
matched.filter((n) => inRootNames.has(n.name))
|
||||
).length
|
||||
})
|
||||
|
||||
const hoveredNodeDef = computed(
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -96,9 +96,11 @@
|
||||
class="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
|
||||
query: searchQuery
|
||||
})
|
||||
disabledMatchCount > 0
|
||||
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
|
||||
: $t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
|
||||
query: searchQuery
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<AllNodesPanel
|
||||
@@ -139,6 +141,7 @@ import TabPanel from '@/components/tab/TabPanel.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import { ESSENTIAL_SECTIONS } from '@/constants/essentialsNodes'
|
||||
@@ -277,6 +280,17 @@ const hasNoMatches = computed(
|
||||
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
|
||||
)
|
||||
|
||||
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
|
||||
const disabledMatchCount = computed(() => {
|
||||
if (!hasNoMatches.value || disabledNodeDefs.value.length === 0) return 0
|
||||
return disabledSearchService.value.searchNode(
|
||||
searchQuery.value,
|
||||
[],
|
||||
{ limit: 64 },
|
||||
{ matchWildcards: false }
|
||||
).length
|
||||
})
|
||||
|
||||
const sections = computed(() => {
|
||||
return nodeOrganizationService.organizeNodesTab(activeNodes.value)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<Toast group="disabled-nodes" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span class="text-sm font-semibold">
|
||||
{{ slotProps.message.summary }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ slotProps.message.detail }}
|
||||
</span>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
v-if="canViewErrors"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="viewDisabledNodeDetails(slotProps.message)"
|
||||
>
|
||||
{{ $t('rightSidePanel.disabledNodes.viewDetails') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
<Toast group="billing-operation" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -12,15 +34,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toast from 'primevue/toast'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
const toast = useToast()
|
||||
const toastStore = useToastStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canViewErrors = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
function viewDisabledNodeDetails(message: ToastMessageOptions) {
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
toast.remove(message)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toastStore.messagesToAdd,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] size-4 text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
@@ -79,34 +79,6 @@ vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
const agentPanelHolder = vi.hoisted(() => ({
|
||||
store: null as unknown as {
|
||||
isOpen: { value: boolean }
|
||||
enabled: { value: boolean }
|
||||
toggle: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
vi.mock(
|
||||
'@/workbench/extensions/agent/stores/agent/agentPanelStore',
|
||||
async () => {
|
||||
const { ref } = await import('vue')
|
||||
agentPanelHolder.store = {
|
||||
isOpen: ref(false),
|
||||
enabled: ref(false),
|
||||
toggle: vi.fn(() => {
|
||||
agentPanelHolder.store.isOpen.value =
|
||||
!agentPanelHolder.store.isOpen.value
|
||||
})
|
||||
}
|
||||
return { useAgentPanelStore: () => agentPanelHolder.store }
|
||||
}
|
||||
)
|
||||
|
||||
const trackAgentEntryButtonClicked = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackAgentEntryButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
@@ -159,42 +131,6 @@ function renderComponent() {
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('WorkflowTabs agent entry button', () => {
|
||||
beforeEach(() => {
|
||||
tabBarLayout.value = 'Integrated'
|
||||
agentPanelHolder.store.enabled.value = true
|
||||
agentPanelHolder.store.isOpen.value = false
|
||||
trackAgentEntryButtonClicked.mockClear()
|
||||
agentPanelHolder.store.toggle.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
agentPanelHolder.store.enabled.value = false
|
||||
agentPanelHolder.store.isOpen.value = false
|
||||
})
|
||||
|
||||
it('reports the entry click with the state the click produces', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
|
||||
)
|
||||
expect(trackAgentEntryButtonClicked).toHaveBeenCalledWith({
|
||||
resulting_state: 'opened'
|
||||
})
|
||||
expect(agentPanelHolder.store.toggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
agentPanelHolder.store.isOpen.value = true
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: enMessages.agent.askComfyAgent })
|
||||
)
|
||||
expect(trackAgentEntryButtonClicked).toHaveBeenLastCalledWith({
|
||||
resulting_state: 'closed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
|
||||
@@ -84,24 +84,6 @@
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
v-if="agentPanelEnabled"
|
||||
variant="link"
|
||||
size="sm"
|
||||
:class="
|
||||
cn(
|
||||
'no-drag shrink-0 border border-solid text-base-foreground',
|
||||
isAgentPanelOpen
|
||||
? 'border-plum-500 bg-plum-600/20'
|
||||
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('agent.askComfyAgent')"
|
||||
@click="onAgentEntryClick"
|
||||
>
|
||||
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
|
||||
<span>{{ $t('agent.askComfyAgent') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -124,9 +106,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
@@ -146,8 +126,6 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
@@ -167,16 +145,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
function onAgentEntryClick(): void {
|
||||
useTelemetry()?.trackAgentEntryButtonClicked({
|
||||
resulting_state: isAgentPanelOpen.value ? 'closed' : 'opened'
|
||||
})
|
||||
agentPanelStore.toggle()
|
||||
}
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
|
||||
38
src/components/ui/checkbox/Checkbox.vue
Normal file
38
src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwardedProps"
|
||||
v-model="checked"
|
||||
:class="
|
||||
cn(
|
||||
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-white',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="flex items-center justify-center">
|
||||
<i
|
||||
:class="
|
||||
checked === 'indeterminate'
|
||||
? 'icon-[lucide--minus] size-3'
|
||||
: 'icon-[lucide--check] size-3'
|
||||
"
|
||||
/>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootProps } from 'reka-ui'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type Props = Omit<CheckboxRootProps, 'defaultValue' | 'modelValue'> & {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const { class: className, ...restProps } = defineProps<Props>()
|
||||
const forwardedProps = useForwardProps(restProps)
|
||||
const checked = defineModel<boolean | 'indeterminate'>({ default: false })
|
||||
</script>
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, watch } from 'vue'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
@@ -34,7 +35,8 @@ function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>,
|
||||
missingMediaExecIds: Set<string> = new Set()
|
||||
missingMediaExecIds: Set<string> = new Set(),
|
||||
disabledNodeExecIds: Set<string> = new Set()
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
@@ -71,6 +73,11 @@ function reconcileNodeErrorFlags(
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
for (const execId of disabledNodeExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
@@ -84,9 +91,10 @@ function reconcileNodeErrorFlags(
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
nodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>,
|
||||
disabledPartnerNodesStore: ReturnType<typeof useDisabledPartnerNodesStore>
|
||||
): () => void {
|
||||
const settingStore = useSettingStore()
|
||||
const showErrorsTab = computed(() =>
|
||||
@@ -95,9 +103,10 @@ export function useNodeErrorFlagSync(
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
lastNodeErrors,
|
||||
nodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
() => disabledPartnerNodesStore.disabledAncestorExecutionIds,
|
||||
showErrorsTab
|
||||
],
|
||||
() => {
|
||||
@@ -108,12 +117,15 @@ export function useNodeErrorFlagSync(
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
lastNodeErrors.value,
|
||||
nodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
showErrorsTab.value
|
||||
? missingMediaStore.missingMediaAncestorExecutionIds
|
||||
: new Set(),
|
||||
showErrorsTab.value
|
||||
? disabledPartnerNodesStore.disabledAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
|
||||
20
src/composables/node/useDisabledNodeSearch.ts
Normal file
20
src/composables/node/useDisabledNodeSearch.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
export function useDisabledNodeSearch() {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
|
||||
const disabledNodeDefs = computed(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName).filter((nodeDef) =>
|
||||
disabledPartnerNodesStore.isNodeDefDisabled(nodeDef)
|
||||
)
|
||||
)
|
||||
const disabledSearchService = computed(
|
||||
() => new NodeSearchService(disabledNodeDefs.value)
|
||||
)
|
||||
|
||||
return { disabledNodeDefs, disabledSearchService }
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
@@ -133,6 +134,13 @@ export function useFeatureFlags() {
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get partnerNodeGovernanceEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
|
||||
remoteConfig.value.partner_node_governance_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('liftNodeErrorsToBoundary', () => {
|
||||
it('lifts a boundary-linked slot error to the host', () => {
|
||||
const { host, rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual({
|
||||
'12': {
|
||||
class_type: host.title,
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
type: 'required_input_missing',
|
||||
extra_info: expect.objectContaining({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('lifts a promoted-widget value error to the host input', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode('CheckpointLoaderSimple')
|
||||
interior.id = toNodeId(5)
|
||||
const input = interior.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
|
||||
values: ['present.safetensors']
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
subgraph.add(interior)
|
||||
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('value_not_in_list', 'ckpt_name', {
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'ckpt_name',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'ckpt_name',
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('recurses through nested boundary-linked hosts', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
outerHost.title = 'Outer Host'
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(Object.keys(result)).toEqual(['1'])
|
||||
expect(result['1'].class_type).toBe(outerHost.title)
|
||||
expect(result['1'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '1:2:3',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps errors on ordinary interior data-flow links', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const source = new LGraphNode('SourceNode')
|
||||
source.id = toNodeId(4)
|
||||
source.addOutput('seed', '*')
|
||||
subgraph.add(source)
|
||||
|
||||
const target = new LGraphNode('TargetNode')
|
||||
target.id = toNodeId(5)
|
||||
target.addInput('seed_input', '*')
|
||||
subgraph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps errors without a liftable subject on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing'),
|
||||
validationError('exception_during_validation', 'seed_input'),
|
||||
validationError('dependency_cycle', 'seed_input'),
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{ received_value: 'image.png' },
|
||||
'Invalid image file'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps unknown typed validation errors on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('future_backend_validation_type', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('splits liftable and non-liftable errors from the same node entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input'),
|
||||
validationError('exception_during_validation', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors).toHaveLength(1)
|
||||
expect(result['12'].errors[0].type).toBe('required_input_missing')
|
||||
expect(result['12:5'].errors).toHaveLength(1)
|
||||
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
|
||||
})
|
||||
|
||||
it('merges a lifted error into an existing host entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12': {
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output'],
|
||||
errors: [validationError('value_smaller_than_min', 'other')]
|
||||
},
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result['12']).toMatchObject({
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output']
|
||||
})
|
||||
expect(result['12'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps own errors before lifted errors for nested host keys', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
]),
|
||||
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
|
||||
})
|
||||
|
||||
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves empty error entries unchanged', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const errors = {
|
||||
'12': nodeError([], 'ExtraRootNode')
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('fails open without mutating the input record', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
const interior = new LGraphNode('InteriorNode')
|
||||
interior.id = toNodeId(5)
|
||||
interior.addInput('unlinked', '*')
|
||||
subgraph.add(interior)
|
||||
|
||||
const errors = {
|
||||
'99:5': nodeError([validationError('required_input_missing', 'x')]),
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'missing'),
|
||||
validationError('value_not_in_list', 'unlinked')
|
||||
])
|
||||
}
|
||||
const original = structuredClone(errors)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual(original)
|
||||
expect(errors).toEqual(original)
|
||||
expect(result).not.toBe(errors)
|
||||
})
|
||||
})
|
||||
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { groupBy, partition } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export interface LiftedErrorExtraInfo {
|
||||
input_name: string
|
||||
source_execution_id: string
|
||||
source_input_name: string
|
||||
}
|
||||
|
||||
export interface LiftedSurface {
|
||||
hostExecId: NodeExecutionId
|
||||
hostInputName: string
|
||||
}
|
||||
|
||||
interface ErrorPlacement {
|
||||
kind: 'own' | 'lifted'
|
||||
targetExecId: string
|
||||
error: NodeValidationError
|
||||
}
|
||||
|
||||
export function getLiftedErrorSource(
|
||||
error: NodeValidationError
|
||||
): LiftedErrorExtraInfo | null {
|
||||
const extraInfo = error.extra_info
|
||||
if (!extraInfo) return null
|
||||
|
||||
const { input_name, source_execution_id, source_input_name } = extraInfo
|
||||
if (
|
||||
typeof input_name !== 'string' ||
|
||||
typeof source_execution_id !== 'string' ||
|
||||
typeof source_input_name !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { input_name, source_execution_id, source_input_name }
|
||||
}
|
||||
|
||||
function getHostExecutionId(executionId: string): NodeExecutionId | null {
|
||||
const separatorIndex = executionId.lastIndexOf(':')
|
||||
if (separatorIndex <= 0) return null
|
||||
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
|
||||
* Walks one host per level and stops at the last resolvable surface, so an
|
||||
* unresolvable deeper host falls back to the shallower one (fail-open).
|
||||
*/
|
||||
export function resolveLiftChain(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
): LiftedSurface[] {
|
||||
const chain: LiftedSurface[] = []
|
||||
let currentExecId = executionId
|
||||
let currentInputName = inputName
|
||||
|
||||
for (;;) {
|
||||
const node = getNodeByExecutionId(rootGraph, currentExecId)
|
||||
const graph = node?.graph
|
||||
if (!node || !graph || !isSubgraph(graph)) break
|
||||
|
||||
const slot = node.inputs?.find((input) => input.name === currentInputName)
|
||||
if (slot?.link == null) break
|
||||
|
||||
const subgraphInput = graph
|
||||
.getLink(slot.link)
|
||||
?.resolve(graph)?.subgraphInput
|
||||
if (!subgraphInput) break
|
||||
|
||||
const hostExecId = getHostExecutionId(currentExecId)
|
||||
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
|
||||
|
||||
chain.push({ hostExecId, hostInputName: subgraphInput.name })
|
||||
currentExecId = hostExecId
|
||||
currentInputName = subgraphInput.name
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
function createEmptyNodeError(nodeError: NodeError): NodeError {
|
||||
return {
|
||||
...nodeError,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
|
||||
function createLiftedHostEntry(
|
||||
rootGraph: LGraph,
|
||||
hostExecId: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type:
|
||||
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
|
||||
dependent_outputs: [],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorPlacement(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
error: NodeValidationError
|
||||
): ErrorPlacement {
|
||||
const inputName = error.extra_info?.input_name
|
||||
const surface =
|
||||
inputName && !isNodeLevelValidationError(error)
|
||||
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
|
||||
: undefined
|
||||
|
||||
if (!inputName || !surface) {
|
||||
return {
|
||||
kind: 'own',
|
||||
targetExecId: executionId,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
const liftedExtraInfo: LiftedErrorExtraInfo = {
|
||||
input_name: surface.hostInputName,
|
||||
source_execution_id: executionId,
|
||||
source_input_name: inputName
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'lifted',
|
||||
targetExecId: surface.hostExecId,
|
||||
error: {
|
||||
...error,
|
||||
extra_info: {
|
||||
...error.extra_info,
|
||||
...liftedExtraInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function liftNodeErrorsToBoundary(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Record<string, NodeError> {
|
||||
const output: Record<string, NodeError> = {}
|
||||
const placements = Object.entries(nodeErrors).flatMap(
|
||||
([executionId, nodeError]) =>
|
||||
nodeError.errors.map((error) =>
|
||||
toErrorPlacement(rootGraph, executionId, error)
|
||||
)
|
||||
)
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
if (nodeError.errors.length === 0) {
|
||||
output[executionId] = createEmptyNodeError(nodeError)
|
||||
}
|
||||
}
|
||||
|
||||
const placementsByTarget = groupBy(
|
||||
placements,
|
||||
(placement) => placement.targetExecId
|
||||
)
|
||||
|
||||
for (const [targetExecId, targetPlacements] of Object.entries(
|
||||
placementsByTarget
|
||||
)) {
|
||||
const baseEntry = nodeErrors[targetExecId]
|
||||
? createEmptyNodeError(nodeErrors[targetExecId])
|
||||
: createLiftedHostEntry(rootGraph, targetExecId)
|
||||
|
||||
const [ownErrors, liftedErrors] = partition(
|
||||
targetPlacements,
|
||||
(placement) => placement.kind === 'own'
|
||||
)
|
||||
|
||||
output[targetExecId] = {
|
||||
...baseEntry,
|
||||
errors: [...ownErrors, ...liftedErrors].map(
|
||||
(placement) => placement.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedExtensions: [] as ComfyExtension[],
|
||||
agentStore: { enabled: false, close: vi.fn() },
|
||||
flagEnabled: undefined as boolean | undefined,
|
||||
flagListener: null as (() => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: ComfyExtension) => {
|
||||
mocks.capturedExtensions.push(ext)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/agent/stores/agent/agentPanelStore', () => ({
|
||||
useAgentPanelStore: () => mocks.agentStore
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
isFeatureEnabled: () => mocks.flagEnabled,
|
||||
onFeatureFlags: (listener: () => void) => {
|
||||
mocks.flagListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function loadEntryAndSetup(): Promise<void> {
|
||||
await import('./agentPanel')
|
||||
const ext = mocks.capturedExtensions.find(
|
||||
(e) => e.name === 'Comfy.AgentPanel'
|
||||
)
|
||||
expect(ext).toBeDefined()
|
||||
ext!.setup!({} as Parameters<NonNullable<ComfyExtension['setup']>>[0])
|
||||
for (let i = 0; i < 20 && mocks.flagListener === null; i++) await flush()
|
||||
expect(mocks.flagListener).toBeTypeOf('function')
|
||||
}
|
||||
|
||||
describe('AgentPanel extension flag gate', () => {
|
||||
beforeEach(() => {
|
||||
mocks.capturedExtensions.length = 0
|
||||
mocks.agentStore.close.mockClear()
|
||||
mocks.agentStore.enabled = false
|
||||
mocks.flagEnabled = undefined
|
||||
mocks.flagListener = null
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('leaves the panel disabled while the flag is undefined', async () => {
|
||||
await loadEntryAndSetup()
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables the panel when the flag turns true', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
expect(mocks.agentStore.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables and closes the panel when the flag flips back to false', async () => {
|
||||
await loadEntryAndSetup()
|
||||
mocks.flagEnabled = true
|
||||
mocks.flagListener!()
|
||||
mocks.flagEnabled = false
|
||||
mocks.flagListener!()
|
||||
|
||||
expect(mocks.agentStore.enabled).toBe(false)
|
||||
expect(mocks.agentStore.close).toHaveBeenCalledWith('flag_disabled')
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createPostHogFlagSource } from '@/workbench/extensions/agent/composables/agent/useAgentFeatureGate'
|
||||
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.AgentPanel',
|
||||
setup() {
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
async function setupFlagGate(): Promise<void> {
|
||||
const posthog = (await import('posthog-js')).default
|
||||
const source = createPostHogFlagSource(posthog)
|
||||
const sync = (): void => {
|
||||
const forceInDev = import.meta.env.MODE === 'development'
|
||||
const enabled = forceInDev || source.isEnabled()
|
||||
agentPanelStore.enabled = enabled
|
||||
if (!enabled) agentPanelStore.close('flag_disabled')
|
||||
}
|
||||
source.onChange?.(sync)
|
||||
sync()
|
||||
}
|
||||
|
||||
void setupFlagGate()
|
||||
}
|
||||
})
|
||||
@@ -38,7 +38,6 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./agentPanel')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -3,23 +3,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
|
||||
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
|
||||
vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn()
|
||||
}))
|
||||
const {
|
||||
mockSetMode,
|
||||
mockAttach,
|
||||
mockDetach,
|
||||
mockGetHelper,
|
||||
mockDispose,
|
||||
transformControlsInstances,
|
||||
omitGetPointer
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn(),
|
||||
transformControlsInstances: [] as unknown[],
|
||||
omitGetPointer: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/TransformControls', () => {
|
||||
class TransformControls {
|
||||
enabled = true
|
||||
dragging = false
|
||||
camera: THREE.Camera
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
private listeners = new Map<string, ((e: unknown) => void)[]>()
|
||||
|
||||
constructor(camera: THREE.Camera) {
|
||||
this.camera = camera
|
||||
if (!omitGetPointer.value) {
|
||||
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
|
||||
}
|
||||
transformControlsInstances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (e: unknown) => void) {
|
||||
@@ -64,6 +83,8 @@ describe('GizmoManager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
transformControlsInstances.length = 0
|
||||
omitGetPointer.value = false
|
||||
|
||||
scene = new THREE.Scene()
|
||||
interactionElement = document.createElement('div')
|
||||
@@ -89,6 +110,120 @@ describe('GizmoManager', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('setPointerNdcSource', () => {
|
||||
type PointerNdc = { x: number; y: number; button: number }
|
||||
function lastControls() {
|
||||
return transformControlsInstances.at(-1) as {
|
||||
dragging: boolean
|
||||
_getPointer?: (event: PointerEvent) => PointerNdc
|
||||
}
|
||||
}
|
||||
function getPointerOverride() {
|
||||
return lastControls()._getPointer
|
||||
}
|
||||
|
||||
it('routes TransformControls pointer NDC through the injected source', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource((clientX, clientY) => ({
|
||||
x: clientX / 100,
|
||||
y: clientY / 100,
|
||||
inside: true
|
||||
}))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 50,
|
||||
clientY: -25,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('maps unmappable points to an off-screen pointer', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => null)
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
lastControls().dragging = true
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: -1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
|
||||
})
|
||||
|
||||
it('applies a source registered before init once init runs', () => {
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
manager.init()
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
|
||||
})
|
||||
|
||||
it('delegates to the stock mapping until a source is registered', () => {
|
||||
manager.init()
|
||||
|
||||
const stock = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
|
||||
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
|
||||
|
||||
const mapped = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('warns and skips the override when _getPointer is missing at init', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
omitGetPointer.value = true
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
|
||||
manager.init()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
|
||||
expect(lastControls()._getPointer).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('adds helper to scene with correct name and render order', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -4,6 +4,9 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode, Model3DTransform } from './interfaces'
|
||||
import type { PointerNdcSource } from './load3dViewport'
|
||||
|
||||
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
@@ -18,6 +21,7 @@ export class GizmoManager {
|
||||
private interactionElement: HTMLElement
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
private getPointerNdc?: PointerNdcSource
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -46,12 +50,45 @@ export class GizmoManager {
|
||||
}
|
||||
})
|
||||
|
||||
this.installPointerNdcOverride()
|
||||
|
||||
const helper = this.transformControls.getHelper()
|
||||
helper.name = 'GizmoTransformControls'
|
||||
helper.renderOrder = 999
|
||||
this.scene.add(helper)
|
||||
}
|
||||
|
||||
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
|
||||
this.getPointerNdc = getPointerNdc
|
||||
}
|
||||
|
||||
private installPointerNdcOverride(): void {
|
||||
if (!this.transformControls) return
|
||||
const transformControls = this.transformControls
|
||||
const controls = transformControls as unknown as {
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
}
|
||||
const original = controls._getPointer
|
||||
if (typeof original !== 'function') {
|
||||
console.warn(
|
||||
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
|
||||
)
|
||||
return
|
||||
}
|
||||
controls._getPointer = (event: PointerEvent) => {
|
||||
if (!this.getPointerNdc) return original.call(transformControls, event)
|
||||
const ndc = this.getPointerNdc(event.clientX, event.clientY)
|
||||
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
|
||||
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
|
||||
}
|
||||
return { x: ndc.x, y: ndc.y, button: event.button }
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(model: THREE.Object3D): void {
|
||||
if (!this.transformControls) return
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type {
|
||||
CameraState,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
@@ -1260,4 +1262,102 @@ describe('Load3d', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor wiring', () => {
|
||||
function makeConstructorDeps() {
|
||||
const container = document.createElement('div')
|
||||
const canvas = document.createElement('canvas')
|
||||
container.appendChild(canvas)
|
||||
|
||||
const view = {
|
||||
canvas,
|
||||
renderer: {
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
|
||||
observeResize: vi.fn(),
|
||||
beginRender: vi.fn(),
|
||||
blit: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const gizmoManager = {
|
||||
setPointerNdcSource: vi.fn(),
|
||||
init: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const deps = {
|
||||
view,
|
||||
eventManager: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
},
|
||||
sceneManager: {
|
||||
init: vi.fn(),
|
||||
scene: new THREE.Scene(),
|
||||
renderBackground: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
cameraManager: {
|
||||
init: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
|
||||
lightingManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
viewHelperManager: {
|
||||
createViewHelper: vi.fn(),
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
render: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
hdriManager: { dispose: vi.fn() },
|
||||
loaderManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
modelManager: { dispose: vi.fn() },
|
||||
recordingManager: {
|
||||
getIsRecording: vi.fn(() => false),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
animationManager: {
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
isAnimationPlaying: false,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
gizmoManager,
|
||||
adapterRef: { current: null, capabilities: null }
|
||||
}
|
||||
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
|
||||
}
|
||||
|
||||
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
|
||||
const { container, deps, gizmoManager } = makeConstructorDeps()
|
||||
const load3d = new Load3d(container, deps)
|
||||
|
||||
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
|
||||
|
||||
const ndc = { x: 0.25, y: -0.5, inside: true }
|
||||
const clientPointToNdc = vi
|
||||
.spyOn(load3d, 'clientPointToNdc')
|
||||
.mockReturnValue(ndc)
|
||||
const source = gizmoManager.setPointerNdcSource.mock
|
||||
.calls[0][0] as PointerNdcSource
|
||||
|
||||
expect(source(12, 34)).toBe(ndc)
|
||||
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
|
||||
|
||||
load3d.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,9 @@ class Load3d extends Viewport3d {
|
||||
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
|
||||
this.clientPointToNdc(clientX, clientY)
|
||||
)
|
||||
this.gizmoManager.init()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
|
||||
@@ -386,6 +386,67 @@ describe('Viewport3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToNdc', () => {
|
||||
function installCanvas(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
...rect,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
Object.assign(ctx.viewport, { view: { canvas } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 100,
|
||||
targetHeight: 100,
|
||||
targetAspectRatio: 1,
|
||||
isViewerMode: false
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
|
||||
x: expect.closeTo(0),
|
||||
y: expect.closeTo(0),
|
||||
inside: true
|
||||
})
|
||||
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
|
||||
x: expect.closeTo(-1.5),
|
||||
y: expect.closeTo(0),
|
||||
inside: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the canvas has no layout size', () => {
|
||||
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the full canvas when no aspect ratio is maintained', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
|
||||
x: expect.closeTo(-1),
|
||||
y: expect.closeTo(1),
|
||||
inside: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('start / remove lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
import { normalize } from '@/utils/mathUtil'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
@@ -17,7 +18,12 @@ import type {
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import type { LetterboxNdc } from './load3dViewport'
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
|
||||
const VIEW_HELPER_SIZE = 128
|
||||
|
||||
@@ -276,6 +282,17 @@ export class Viewport3d {
|
||||
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
|
||||
}
|
||||
|
||||
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
|
||||
const rect = this.domElement.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
return clientPointToLetterboxNdc(
|
||||
normalize(clientX, rect.left, rect.right),
|
||||
normalize(clientY, rect.top, rect.bottom),
|
||||
{ width: rect.width, height: rect.height },
|
||||
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
|
||||
)
|
||||
}
|
||||
|
||||
protected startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
import type { Load3dActivityFlags } from './load3dViewport'
|
||||
|
||||
describe('computeLetterboxedViewport', () => {
|
||||
@@ -106,3 +110,59 @@ describe('isLoad3dActive', () => {
|
||||
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToLetterboxNdc', () => {
|
||||
function ndc(x: number, y: number, inside = true) {
|
||||
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
|
||||
}
|
||||
|
||||
it('maps the full canvas when no target aspect is set', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(0, 0))
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(-1, -1))
|
||||
})
|
||||
|
||||
it('maps pillarboxed content edges to -1/1', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
|
||||
ndc(-1, 0)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
|
||||
})
|
||||
|
||||
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
|
||||
ndc(-1.6, 0, false)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
|
||||
ndc(1.6, 0, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('handles letterbox bars above/below wide content', () => {
|
||||
const container = { width: 200, height: 400 }
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
|
||||
ndc(0, 1)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
|
||||
ndc(0, 3.2, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null instead of NaN for zero-size containers', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { denormalize, normalize } from '@/utils/mathUtil'
|
||||
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
type LetterboxedViewport = {
|
||||
@@ -34,6 +36,39 @@ export function computeLetterboxedViewport(
|
||||
}
|
||||
}
|
||||
|
||||
export type LetterboxNdc = { x: number; y: number; inside: boolean }
|
||||
|
||||
export type PointerNdcSource = (
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => LetterboxNdc | null
|
||||
|
||||
export function clientPointToLetterboxNdc(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
container: Size,
|
||||
targetAspectRatio: number | null
|
||||
): LetterboxNdc | null {
|
||||
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
|
||||
x: denormalize(localX, -1, 1),
|
||||
y: -denormalize(localY, -1, 1),
|
||||
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
|
||||
})
|
||||
|
||||
if (targetAspectRatio === null) {
|
||||
return toNdc(normalizedX, normalizedY)
|
||||
}
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
container,
|
||||
targetAspectRatio
|
||||
)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
return toNdc(
|
||||
normalize(normalizedX * container.width, offsetX, offsetX + width),
|
||||
normalize(normalizedY * container.height, offsetY, offsetY + height)
|
||||
)
|
||||
}
|
||||
|
||||
export type Load3dActivityFlags = {
|
||||
mouseOnNode: boolean
|
||||
mouseOnScene: boolean
|
||||
|
||||
80
src/i18n.safeTranslation.test.ts
Normal file
80
src/i18n.safeTranslation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { i18n, st, stRaw } from './i18n'
|
||||
|
||||
const TEST_NAMESPACE = 'safeTranslationTest'
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
const messages = i18n.global.getLocaleMessage('en')
|
||||
delete (messages as Record<string, unknown>)[TEST_NAMESPACE]
|
||||
i18n.global.setLocaleMessage('en', messages)
|
||||
})
|
||||
|
||||
describe('st', () => {
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(st('safeTranslationTest.missing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses compiled translations for valid locale messages', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
valid: 'Translated value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(st('safeTranslationTest.valid', 'Fallback value')).toBe(
|
||||
'Translated value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw locale messages when vue-i18n compilation fails', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
invalidLinkedFormat: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
st('safeTranslationTest.invalidLinkedFormat', 'Fallback value')
|
||||
).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stRaw', () => {
|
||||
it('returns raw locale messages for valid keys', () => {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawValue: 'Raw value'
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe(
|
||||
'Raw value'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns raw messages containing vue-i18n syntax', () => {
|
||||
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
|
||||
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
safeTranslationTest: {
|
||||
rawSyntax: message
|
||||
}
|
||||
})
|
||||
|
||||
expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe(
|
||||
message
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the fallback when the key is not found', () => {
|
||||
expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe(
|
||||
'Fallback value'
|
||||
)
|
||||
})
|
||||
})
|
||||
20
src/i18n.ts
20
src/i18n.ts
@@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
* Invalid message syntax falls back to the raw locale message instead of crashing.
|
||||
*
|
||||
* @param key - The key to translate.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found.
|
||||
*/
|
||||
export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
try {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return t(key)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) {
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions {
|
||||
size?: [number, number]
|
||||
}
|
||||
|
||||
interface BoundaryLinkedSubgraphOptions {
|
||||
rootGraph?: LGraph
|
||||
hostId?: SerializedNodeId
|
||||
interiorId?: SerializedNodeId
|
||||
boundaryName?: string
|
||||
inputName?: string
|
||||
hostTitle?: string
|
||||
interiorType?: string
|
||||
}
|
||||
|
||||
export interface BoundaryLinkedSubgraphFixture {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
host: SubgraphNode
|
||||
interior: LGraphNode
|
||||
}
|
||||
|
||||
interface NestedSubgraphOptions {
|
||||
depth?: number
|
||||
nodesPerLevel?: number
|
||||
@@ -269,6 +287,32 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function createBoundaryLinkedSubgraph({
|
||||
rootGraph = createTestRootGraph(),
|
||||
hostId = 12,
|
||||
interiorId = 5,
|
||||
boundaryName = 'seed',
|
||||
inputName = 'seed_input',
|
||||
hostTitle = 'Host Subgraph',
|
||||
interiorType = 'InteriorNode'
|
||||
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: boundaryName, type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host.title = hostTitle
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode(interiorType)
|
||||
interior.id = toNodeId(interiorId)
|
||||
const input = interior.addInput(inputName, '*')
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(input, interior)
|
||||
|
||||
return { rootGraph, subgraph, host, interior }
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
|
||||
@@ -1091,6 +1091,7 @@
|
||||
"runWorkflow": "Run workflow (Shift to queue at front)",
|
||||
"runWorkflowFront": "Run workflow (Queue at front)",
|
||||
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
|
||||
"runWorkflowDisabledNodes": "Workflows with disabled nodes cannot be run. Check the Errors tab for details.",
|
||||
"run": "Run",
|
||||
"stopRunInstant": "Stop Run (Instant)",
|
||||
"stopRunInstantTooltip": "Stop running",
|
||||
@@ -1565,6 +1566,8 @@
|
||||
"Execution": "Execution",
|
||||
"PLY": "PLY",
|
||||
"Workspace": "Workspace",
|
||||
"Members": "Members",
|
||||
"PartnerNodes": "Allowlist",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets",
|
||||
@@ -2630,7 +2633,7 @@
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"usedAfterMonthly": "Used after plan credits run out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
@@ -2868,7 +2871,10 @@
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"enterprisePlanName": "Enterprise",
|
||||
"percentUsed": "{percent}% used",
|
||||
"usageProgress": "{used} of {total} credits used"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2883,7 +2889,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"inviteLimitReached": "Your workspace is at the member limit",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2898,12 +2904,17 @@
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
"pendingCount": "Pending ({count})",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"creditsUsed": "Credits used this month",
|
||||
"email": "Email",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2919,14 +2930,23 @@
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago",
|
||||
"never": "—"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
|
||||
"renameWorkspace": "Rename Workspace"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2951,12 +2971,12 @@
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteTitle": "Make {name} an admin?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"promotePermissionRoles": "Promote and demote other admins (except the workspace creator).",
|
||||
"promoteConfirm": "Make admin",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
@@ -3015,6 +3035,105 @@
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"memberLimitDialog": {
|
||||
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
|
||||
"title": "Workspace is at the member limit"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"workflowQueuedDialog": {
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
|
||||
"title": "Your workflow is queued"
|
||||
},
|
||||
"billingStatus": {
|
||||
"ending": {
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan",
|
||||
"title": "Your team plan ends on {date}"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"addCredits": "Add credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Out of credits"
|
||||
},
|
||||
"paused": {
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
|
||||
"title": "Subscription paused"
|
||||
},
|
||||
"updatePayment": "Update payment",
|
||||
"warning": {
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"title": "Payment declined"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"changePlan": "Change plan",
|
||||
"inactive": {
|
||||
"reactivate": "Reactivate plan",
|
||||
"subtitle": "Reactivate your team plan to add more members and run workflows",
|
||||
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
|
||||
"title": "Inactive team subscription",
|
||||
"titleEnterprise": "Inactive enterprise subscription"
|
||||
},
|
||||
"learnMore": "Learn more",
|
||||
"managePayment": "Manage payment",
|
||||
"messageSupport": "Message support",
|
||||
"paused": "Paused",
|
||||
"perMonth": "mo",
|
||||
"pricingTable": "Partner Node pricing table",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"seeMore": "See more",
|
||||
"snapshot": {
|
||||
"creditsUsed": "Credits used",
|
||||
"empty": {
|
||||
"recentActivity": "No activity yet",
|
||||
"topSpenders": "No credits used yet this month"
|
||||
},
|
||||
"lastActivity": "Last activity",
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"allowlist": {
|
||||
"disableAll": "Disable all",
|
||||
"enableAll": "Enable all",
|
||||
"tabs": {
|
||||
"partnerNodes": "Partner nodes"
|
||||
}
|
||||
},
|
||||
"partnerNodes": {
|
||||
"autoEnableLabel": "Automatically enable newly added partner nodes",
|
||||
"autoEnableSubject": "newly added partner nodes",
|
||||
"autoEnableVerb": "auto-enable",
|
||||
"bulkToggle": "Enable or disable selected partner nodes",
|
||||
"clearSelection": "Clear selection",
|
||||
"collapseProvider": "Collapse {partner} partner nodes",
|
||||
"columns": {
|
||||
"lastModified": "Last modified",
|
||||
"name": "Partner Node",
|
||||
"nodes": "Nodes"
|
||||
},
|
||||
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
|
||||
"empty": "No partner nodes match your search.",
|
||||
"expandProvider": "Expand {partner} partner nodes",
|
||||
"groupCount": "{enabled}/{total} enabled",
|
||||
"groupToggle": "Enable or disable {partner} partner nodes",
|
||||
"loadError": "Failed to load partner nodes",
|
||||
"loading": "Loading partner nodes...",
|
||||
"neverModified": "—",
|
||||
"nodeToggle": "Enable or disable {name}",
|
||||
"retry": "Retry",
|
||||
"searchPlaceholder": "Search partner nodes",
|
||||
"selectAll": "Select all partner nodes",
|
||||
"selectedCount": "{count} node selected | {count} nodes selected",
|
||||
"updateError": "Failed to update partner nodes"
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
@@ -3027,7 +3146,7 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
@@ -3037,7 +3156,8 @@
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
"failedToSwitch": "Failed to switch workspace",
|
||||
"roleAdmin": "Admin"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
@@ -3924,6 +4044,12 @@
|
||||
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
|
||||
"errorsSummary": "{count} error | {count} errors",
|
||||
"resolveBeforeRun": "Resolve before running the workflow",
|
||||
"disabledNodes": {
|
||||
"title": "Disabled node | Disabled nodes",
|
||||
"message": "This node has been disabled by your team admin. Use a different node. | These nodes have been disabled by your team admin. Use different nodes.",
|
||||
"toastDetail": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin.",
|
||||
"viewDetails": "View details"
|
||||
},
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
@@ -4356,14 +4482,6 @@
|
||||
"prompt_outputs_failed_validation": {
|
||||
"title": "Prompt validation failed",
|
||||
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
|
||||
},
|
||||
"agent_draft_apply_failed": {
|
||||
"title": "An error was found",
|
||||
"desc": "Couldn't apply the agent's draft to the canvas."
|
||||
},
|
||||
"agent_api_failed": {
|
||||
"title": "An error was found",
|
||||
"desc": "Comfy Agent hit a server error."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4431,7 +4549,11 @@
|
||||
"hideDevOnly": "Hide Dev-Only Nodes",
|
||||
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
|
||||
"hideSubgraph": "Hide Subgraph Nodes",
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
|
||||
"hideDisabledPartnerNodes": "Hide Admin-Disabled Partner Nodes"
|
||||
},
|
||||
"nodeSearch": {
|
||||
"disabledByTeamAdmin": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin."
|
||||
},
|
||||
"secrets": {
|
||||
"title": "API Keys & Secrets",
|
||||
@@ -4555,74 +4677,5 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Comfy Agent",
|
||||
"askComfyAgent": "Ask Comfy Agent",
|
||||
"caption": "The agent edits your workflow as a draft. Only you can run it.",
|
||||
"latest": "Latest",
|
||||
"copyUnavailable": "Only the current conversation can be copied in this version",
|
||||
"alpha": "ALPHA",
|
||||
"newChat": "New chat",
|
||||
"newChatTitle": "Untitled",
|
||||
"maximize": "Maximize panel",
|
||||
"minimize": "Minimize panel",
|
||||
"close": "Close",
|
||||
"history": "Chat history",
|
||||
"showChatHistory": "Show chat history",
|
||||
"backToChat": "Back to chat",
|
||||
"historyCurrent": "Current",
|
||||
"historyToday": "Today",
|
||||
"historyYesterday": "Yesterday",
|
||||
"historyEarlier": "Earlier",
|
||||
"historyEmpty": "No conversations yet",
|
||||
"copyMarkdown": "Copy as markdown",
|
||||
"delete": "Delete",
|
||||
"untitledChat": "Untitled",
|
||||
"skip": "Skip",
|
||||
"gotIt": "Got it",
|
||||
"greeting": "Hello {name},",
|
||||
"greetingQuestion": "What do you want to make?",
|
||||
"placeholder": "Ask Comfy Agent…",
|
||||
"suggestedPrompts": [
|
||||
"Generate a yellow duck with a hockey mask",
|
||||
"List my saved workflows",
|
||||
"Find the best workflow for skin upscaling",
|
||||
"Explain the selected node",
|
||||
"Build a workflow for image to video with 3 models"
|
||||
],
|
||||
"attach": "Attach a file",
|
||||
"attachmentTooLarge": "{name} is larger than {limit}",
|
||||
"attachmentUploadFailed": "{name} could not be uploaded",
|
||||
"mention": "Mention a node",
|
||||
"modelAuto": "Auto",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking...",
|
||||
"helpful": "Helpful",
|
||||
"notHelpful": "Not helpful",
|
||||
"toggleRaw": "Toggle raw markdown",
|
||||
"ranToolCalls": "Ran {count} tool call | Ran {count} tool calls",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"remove": "Remove",
|
||||
"uploading": "Uploading",
|
||||
"noNodesToMention": "No nodes in this workflow",
|
||||
"friend": "there",
|
||||
"conflictTitle": "Agent changes are ready",
|
||||
"conflictBody": "You edited this workflow while Comfy Agent changed the same graph. Choose what should happen in this tab.",
|
||||
"keepMine": "Keep my changes",
|
||||
"acceptAgent": "Accept agent changes",
|
||||
"moreApplyOptions": "More apply options",
|
||||
"openNewTab": "Open in new tab",
|
||||
"sendFailed": "Message failed to send",
|
||||
"sendBusy": "A message is already being sent",
|
||||
"malformedEvent": "The agent sent an event this panel could not read",
|
||||
"coachTitle": "Meet the agent",
|
||||
"coachBody": "Ask it to build or edit graphs.",
|
||||
"toolOpenedNewTab": "Opened a new tab",
|
||||
"toolSwitchedTabs": "Switched tabs",
|
||||
"toolSavedPreference": "Saved a preference",
|
||||
"toolForgotPreference": "Forgot a preference"
|
||||
}
|
||||
}
|
||||
|
||||
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
<SelectionBar
|
||||
data-testid="assets-selection-bar"
|
||||
:label="$t('mediaAsset.selection.selectedCount', { count })"
|
||||
:deselect-label="$t('mediaAsset.selection.deselectAll')"
|
||||
@deselect="emit('deselect')"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isPaused: computed(() => false),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
@@ -97,24 +98,14 @@ const i18n = createI18n({
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
refillsNextCycle: 'Refills next cycle',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
monthlyUsageProgress: '{used} of {total} monthly credits used',
|
||||
yearly: 'Yearly',
|
||||
percentUsed: '{percent}% used',
|
||||
usageProgress: '{used} of {total} credits used',
|
||||
additionalCreditsInfo: 'About additional credits',
|
||||
additionalCreditsTooltip: 'Credits you add on top of your plan.',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
usedAfterMonthly: 'Used after plan credits run out',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('98% used')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
expect(container.textContent).toContain('Used after plan credits run out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
it('uses the team credit stop grant for a monthly allowance', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
renderTile()
|
||||
// Allowance is the stop's grant, not the tier fallback.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'527500'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the per-month nominal grant for an annual personal tier', () => {
|
||||
it('grants the full year upfront for an annual plan', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Annual billing still grants the monthly nominal (21,100), not 12x.
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).not.toContain('253,200')
|
||||
renderTile()
|
||||
// Annual plans grant the whole year at once: 21,100 x 12.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'253200'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a dateless refills label when renewal date is missing', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Refills next cycle')
|
||||
expect(container.textContent).not.toContain('Refills Feb')
|
||||
})
|
||||
|
||||
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
|
||||
activeProSubscription()
|
||||
it('labels the allowance by billing duration (yearly for annual)', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: 'not-a-date'
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain("You're out of credits")
|
||||
expect(container.textContent).not.toContain('Credits refill')
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
renderTile()
|
||||
expect(screen.getByText('Yearly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
it('shows no in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
it('shows no in-use badge until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
:class="
|
||||
cn(
|
||||
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
// Paused subscriptions can't spend credits, so dim the whole tile to
|
||||
// read as frozen and defer to the Update-payment banner. A lapsed plan
|
||||
// (frozen) reads the same way.
|
||||
(isPaused || frozen) && 'opacity-50',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -19,8 +28,10 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold tabular-nums">{{
|
||||
displayTotal
|
||||
}}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
@@ -28,37 +39,23 @@
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">{{ cycleLabel }}</span>
|
||||
<span class="text-muted">
|
||||
{{ refillsLabel }}
|
||||
{{ cycleStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-label="cycleLabel"
|
||||
:aria-valuenow="usage.used"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="monthlyTotalCredits ?? 0"
|
||||
:aria-valuetext="monthlyUsageLabel"
|
||||
:aria-valuemax="allowanceTotalCredits ?? 0"
|
||||
:aria-valuetext="cycleUsageLabel"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
@@ -66,40 +63,6 @@
|
||||
:style="{ width: usedBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
@@ -118,7 +81,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="text-muted"
|
||||
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
@@ -132,9 +95,9 @@
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -156,15 +119,10 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
class="w-full font-normal"
|
||||
:disabled="isPaused || frozen"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
@@ -178,6 +136,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -186,40 +145,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
const {
|
||||
zeroState = false,
|
||||
frozen = false,
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
/**
|
||||
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
|
||||
* subscription that still has a shape to show. Mirrors the paused treatment.
|
||||
*/
|
||||
frozen?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
isPaused,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -227,40 +191,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
const cycleLabel = computed(() =>
|
||||
subscription.value?.duration === 'ANNUAL'
|
||||
? t('subscription.yearly')
|
||||
: t('subscription.monthly')
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? ''
|
||||
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
const cycleUsedPercent = computed(() =>
|
||||
Math.round(usage.value.usedFraction * 100)
|
||||
)
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
const cycleStatusLabel = computed(() =>
|
||||
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
|
||||
)
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
@@ -270,82 +212,58 @@ const formatCreditCount = (value: number) =>
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
const allowanceTotalDisplay = computed(() => {
|
||||
const total = allowanceTotalCredits.value
|
||||
return total === null ? '—' : formatCreditCount(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
const monthlyUsageLabel = computed(() =>
|
||||
t('subscription.monthlyUsageProgress', {
|
||||
const cycleUsageLabel = computed(() =>
|
||||
t('subscription.usageProgress', {
|
||||
used: usedDisplay.value,
|
||||
total: monthlyTotalDisplay.value
|
||||
total: allowanceTotalDisplay.value
|
||||
})
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBreakdown = computed(
|
||||
() => (isActiveSubscription.value || frozen) && !zeroState
|
||||
)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
allowanceTotalCredits.value !== null &&
|
||||
allowanceTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
() =>
|
||||
(isActiveSubscription.value || frozen) &&
|
||||
!zeroState &&
|
||||
permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
const isAllowanceDepleted = computed(
|
||||
() =>
|
||||
!isPaused.value &&
|
||||
!frozen &&
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
// Fully out (monthly depleted and no additional credits left): emphasize the
|
||||
// add-credits button. Spending-additional keeps the quieter tertiary.
|
||||
const isOutOfCredits = computed(
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
|
||||
: t('subscription.outOfCreditsTitleNoDate'),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
})
|
||||
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription credit calculations and formatting.
|
||||
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
// Total credits granted for the current billing cycle. Team plans read the
|
||||
// credit stop; personal tiers read the tier grant. Annual plans front-load the
|
||||
// whole year, so multiply the monthly nominal by the cycle length.
|
||||
const cycleMonths = computed(() =>
|
||||
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
|
||||
)
|
||||
const allowanceTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = toValue(billingContext.currentTeamCreditStop)
|
||||
const tier = toValue(billingContext.subscription)?.tier
|
||||
const tierKey = tier
|
||||
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
|
||||
: DEFAULT_TIER_KEY
|
||||
const monthly = teamStop
|
||||
? teamStop.credits_monthly
|
||||
: getTierCredits(tierKey)
|
||||
return monthly === null ? null : monthly * cycleMonths.value
|
||||
})
|
||||
|
||||
// Usage of that allowance drives the credits bar. Paused plans read as unused
|
||||
// (credits are frozen), so force it to zero.
|
||||
const usage = computed(() => {
|
||||
const base = computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
allowanceTotalCredits.value ?? 0
|
||||
)
|
||||
return toValue(billingContext.isPaused)
|
||||
? { ...base, used: 0, usedFraction: 0 }
|
||||
: base
|
||||
})
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,23 +555,6 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the agent draft-apply failure to overlay copy', () => {
|
||||
expect(
|
||||
resolveRunErrorMessage({
|
||||
kind: 'prompt',
|
||||
isCloud: true,
|
||||
error: {
|
||||
type: 'agent_draft_apply_failed',
|
||||
message: "Couldn't apply the agent's draft to the canvas",
|
||||
details: 'Validation error: Required at "version"'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayTitle: 'An error was found',
|
||||
displayMessage: "Couldn't apply the agent's draft to the canvas."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves server_error prompt copy by environment', () => {
|
||||
const error = {
|
||||
type: 'server_error',
|
||||
|
||||
@@ -13,8 +13,7 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
'no_prompt',
|
||||
'server_error',
|
||||
'missing_node_type',
|
||||
'prompt_outputs_failed_validation',
|
||||
'agent_draft_apply_failed'
|
||||
'prompt_outputs_failed_validation'
|
||||
])
|
||||
|
||||
function getPromptExceptionMessage(
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
export type NodeValidationError = NodeError['errors'][number]
|
||||
export type { NodeValidationError }
|
||||
|
||||
export interface ResolvedErrorMessage {
|
||||
catalogId?: string
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
translateOptionalCatalogMessage
|
||||
} from './catalogI18n'
|
||||
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
|
||||
import {
|
||||
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
NODE_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
getInputConfigBounds,
|
||||
isImageNotLoadedValidationError
|
||||
} from '@/utils/executionErrorUtil'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
|
||||
@@ -62,51 +68,31 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
|
||||
Object.fromEntries(
|
||||
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
|
||||
string,
|
||||
ValidationCatalogRule
|
||||
> = Object.fromEntries(
|
||||
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: MISSING_CONNECTION_CATALOG_ID,
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
bad_linked_input: {
|
||||
catalogId: 'bad_linked_input',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
return_type_mismatch: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
invalid_input_type: {
|
||||
catalogId: 'invalid_input_type',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
catalogId: 'value_bigger_than_max',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_not_in_list: {
|
||||
catalogId: 'value_not_in_list',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
custom_validation_failed: {
|
||||
catalogId: 'custom_validation_failed',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_inner_validation: {
|
||||
catalogId: 'exception_during_inner_validation',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_validation: {
|
||||
catalogId: 'exception_during_validation',
|
||||
itemLabel: 'node'
|
||||
},
|
||||
dependency_cycle: {
|
||||
catalogId: 'dependency_cycle',
|
||||
itemLabel: 'node'
|
||||
}
|
||||
...NODE_LEVEL_VALIDATION_ERROR_RULES
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
|
||||
@@ -131,26 +117,6 @@ function getInputName(error: NodeValidationError): string {
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorText(error: NodeValidationError) {
|
||||
return [
|
||||
'message' in error ? error.message : undefined,
|
||||
'details' in error ? error.details : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isImageNotLoadedText(text: string): boolean {
|
||||
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
|
||||
}
|
||||
|
||||
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
isImageNotLoadedText(getErrorText(error))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeInputItemLabel(nodeName: string, inputName: string): string {
|
||||
return `${nodeName} - ${inputName}`
|
||||
}
|
||||
@@ -179,13 +145,7 @@ function getInputConfigValue(
|
||||
error: NodeValidationError,
|
||||
key: 'min' | 'max'
|
||||
): string | undefined {
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
const config = inputConfig[1]
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
|
||||
return formatCatalogValue((config as Record<string, unknown>)[key])
|
||||
return formatCatalogValue(getInputConfigBounds(error)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
|
||||
@@ -107,6 +107,7 @@ export type RemoteConfig = {
|
||||
manager_survey_url?: string
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
partner_node_governance_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout
|
||||
content-title=""
|
||||
data-testid="settings-dialog"
|
||||
size="full"
|
||||
header-height-class="h-22"
|
||||
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -48,6 +54,7 @@
|
||||
id="keybinding-panel-header"
|
||||
class="flex-1"
|
||||
/>
|
||||
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -55,6 +62,7 @@
|
||||
v-if="activeCategoryKey === 'keybinding'"
|
||||
id="keybinding-panel-actions"
|
||||
/>
|
||||
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const { requestedPanelKey } = useSettingsNavigation()
|
||||
watch(requestedPanelKey, (key) => {
|
||||
if (!key) return
|
||||
activeCategoryKey.value = key
|
||||
requestedPanelKey.value = null
|
||||
})
|
||||
|
||||
const searchableNavItems = computed(() =>
|
||||
navGroups.value.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const WORKSPACE_PANEL_KEYS: SettingPanelType[] = [
|
||||
'workspace',
|
||||
'workspace-members',
|
||||
'workspace-partner-nodes'
|
||||
]
|
||||
const isWorkspacePanel = computed(
|
||||
() =>
|
||||
!!activeCategoryKey.value &&
|
||||
WORKSPACE_PANEL_KEYS.some((key) => key === activeCategoryKey.value)
|
||||
)
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
billingType: 'legacy' as 'legacy' | 'workspace',
|
||||
canManagePartnerNodes: false
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
getSettingInfo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockSettingParams {
|
||||
id: string
|
||||
name: string
|
||||
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
billingType: 'legacy',
|
||||
canManagePartnerNodes: false
|
||||
})
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
@@ -233,5 +245,17 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the partner nodes entry only to owners and admins', () => {
|
||||
env.state.canManagePartnerNodes = false
|
||||
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
|
||||
env.state.canManagePartnerNodes = true
|
||||
expect(navKeys(useSettingUI().navGroups.value)).toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type { NavGroupData } from '@/types/navTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PartnerNodes: 'icon-[lucide--shield-check]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
@@ -54,6 +56,7 @@ export function useSettingUI(
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription, type: billingType } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -188,10 +191,40 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const partnerNodesPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-partner-nodes',
|
||||
label: 'PartnerNodes',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/AllowlistPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowWorkspacePanel = computed(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
// Partner-node governance is Owner/Admin-only; Members never see the tab.
|
||||
const shouldShowPartnerNodesPanel = computed(
|
||||
() =>
|
||||
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
|
||||
)
|
||||
|
||||
const secretsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'secrets',
|
||||
@@ -245,7 +278,8 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,7 +329,10 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [workspacePanel.node, membersPanel.node]
|
||||
: []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
|
||||
15
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
15
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SettingPanelType } from '@/platform/settings/types'
|
||||
|
||||
// A one-shot request to switch the open Settings dialog to another panel, so a
|
||||
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const requestedPanelKey = ref<SettingPanelType | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: SettingPanelType) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -87,3 +87,5 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-members'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
@@ -2,13 +2,6 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentEntryButtonClickedMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AgentMessageSentMetadata,
|
||||
AgentNodeTaggedMetadata,
|
||||
AgentPanelClosedMetadata,
|
||||
AgentPanelOpenedMetadata,
|
||||
AgentWorkflowAppliedMetadata,
|
||||
AuthErrorMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
@@ -295,46 +288,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentMessageFeedback?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentPanelOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentPanelClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentEntryButtonClicked(
|
||||
metadata: AgentEntryButtonClickedMetadata
|
||||
): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAgentEntryButtonClicked?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackAgentCloseButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAgentCloseButtonClicked?.())
|
||||
}
|
||||
|
||||
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentMessageSent?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentNodeTagged?.(metadata))
|
||||
}
|
||||
|
||||
trackAgentAttachButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAgentAttachButtonClicked?.())
|
||||
}
|
||||
|
||||
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAgentWorkflowApplied?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
|
||||
@@ -135,29 +135,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("lets the server's person_profiles win over the client default", async () => {
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: { person_profiles: 'always' }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ person_profiles: 'always' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults person_profiles to identified_only when the server omits it', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ person_profiles: 'identified_only' })
|
||||
)
|
||||
})
|
||||
|
||||
it('registers onUserResolved callback after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -893,11 +870,12 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send (PII stripping)', async () => {
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
hoisted.refs.remoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,6 +885,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,13 +11,6 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AgentEntryButtonClickedMetadata,
|
||||
AgentMessageFeedbackMetadata,
|
||||
AgentMessageSentMetadata,
|
||||
AgentNodeTaggedMetadata,
|
||||
AgentPanelClosedMetadata,
|
||||
AgentPanelOpenedMetadata,
|
||||
AgentWorkflowAppliedMetadata,
|
||||
AuthErrorMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
@@ -126,9 +119,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const apiKey =
|
||||
window.__CONFIG__?.posthog_project_token ??
|
||||
import.meta.env.VITE_POSTHOG_PROJECT_TOKEN
|
||||
const apiKey = window.__CONFIG__?.posthog_project_token
|
||||
if (apiKey) {
|
||||
try {
|
||||
void import('posthog-js')
|
||||
@@ -144,8 +135,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
person_profiles: 'identified_only',
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
@@ -578,44 +569,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAgentMessageFeedback(metadata: AgentMessageFeedbackMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_FEEDBACK, metadata)
|
||||
}
|
||||
|
||||
trackAgentPanelOpened(metadata: AgentPanelOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_PANEL_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackAgentPanelClosed(metadata: AgentPanelClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_PANEL_CLOSED, metadata)
|
||||
}
|
||||
|
||||
trackAgentEntryButtonClicked(
|
||||
metadata: AgentEntryButtonClickedMetadata
|
||||
): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_ENTRY_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackAgentCloseButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_CLOSE_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackAgentMessageSent(metadata: AgentMessageSentMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_MESSAGE_SENT, metadata)
|
||||
}
|
||||
|
||||
trackAgentNodeTagged(metadata: AgentNodeTaggedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_NODE_TAGGED, metadata)
|
||||
}
|
||||
|
||||
trackAgentAttachButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_ATTACH_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackAgentWorkflowApplied(metadata: AgentWorkflowAppliedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.AGENT_WORKFLOW_APPLIED, metadata)
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
|
||||
page_name: pageName,
|
||||
|
||||
@@ -401,51 +401,6 @@ export interface UiButtonClickMetadata {
|
||||
element_group: string
|
||||
}
|
||||
|
||||
/**
|
||||
* In-App Agent message rating metadata (PM-98). `vote` is null when the user retracts a
|
||||
* prior thumb, which the eval pipeline records as a retraction rather than dropping.
|
||||
*/
|
||||
export interface AgentMessageFeedbackMetadata extends Record<string, unknown> {
|
||||
message_id: string
|
||||
vote: 'up' | 'down' | null
|
||||
workflow_id: string | null
|
||||
}
|
||||
|
||||
export type AgentPanelCloseSource =
|
||||
| 'topbar_button'
|
||||
| 'close_button'
|
||||
| 'flag_disabled'
|
||||
|
||||
export interface AgentPanelOpenedMetadata extends Record<string, unknown> {
|
||||
source: 'topbar_button'
|
||||
}
|
||||
|
||||
export interface AgentPanelClosedMetadata extends Record<string, unknown> {
|
||||
source: AgentPanelCloseSource
|
||||
open_duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface AgentEntryButtonClickedMetadata extends Record<
|
||||
string,
|
||||
unknown
|
||||
> {
|
||||
resulting_state: 'opened' | 'closed'
|
||||
}
|
||||
|
||||
export interface AgentMessageSentMetadata extends Record<string, unknown> {
|
||||
attachment_count: number
|
||||
node_tag_count: number
|
||||
}
|
||||
|
||||
export interface AgentNodeTaggedMetadata extends Record<string, unknown> {
|
||||
source: 'mention_picker'
|
||||
}
|
||||
|
||||
export interface AgentWorkflowAppliedMetadata extends Record<string, unknown> {
|
||||
workflow_id: string
|
||||
target: 'new_tab' | 'existing_tab' | 'active_tab_open' | 'active_tab_switch'
|
||||
}
|
||||
|
||||
/**
|
||||
* Help center opened metadata
|
||||
*/
|
||||
@@ -687,19 +642,6 @@ export interface TelemetryProvider {
|
||||
// Generic UI button click events
|
||||
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
|
||||
|
||||
// In-App Agent message rating (PM-98)
|
||||
trackAgentMessageFeedback?(metadata: AgentMessageFeedbackMetadata): void
|
||||
|
||||
// In-App Agent panel engagement (FE-1187)
|
||||
trackAgentPanelOpened?(metadata: AgentPanelOpenedMetadata): void
|
||||
trackAgentPanelClosed?(metadata: AgentPanelClosedMetadata): void
|
||||
trackAgentEntryButtonClicked?(metadata: AgentEntryButtonClickedMetadata): void
|
||||
trackAgentCloseButtonClicked?(): void
|
||||
trackAgentMessageSent?(metadata: AgentMessageSentMetadata): void
|
||||
trackAgentNodeTagged?(metadata: AgentNodeTaggedMetadata): void
|
||||
trackAgentAttachButtonClicked?(): void
|
||||
trackAgentWorkflowApplied?(metadata: AgentWorkflowAppliedMetadata): void
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
}
|
||||
@@ -803,17 +745,6 @@ export const TelemetryEvents = {
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// In-App Agent
|
||||
AGENT_MESSAGE_FEEDBACK: 'app:agent_message_feedback',
|
||||
AGENT_PANEL_OPENED: 'app:agent_panel_opened',
|
||||
AGENT_PANEL_CLOSED: 'app:agent_panel_closed',
|
||||
AGENT_ENTRY_BUTTON_CLICKED: 'app:agent_entry_button_clicked',
|
||||
AGENT_CLOSE_BUTTON_CLICKED: 'app:agent_close_button_clicked',
|
||||
AGENT_MESSAGE_SENT: 'app:agent_message_sent',
|
||||
AGENT_NODE_TAGGED: 'app:agent_node_tagged',
|
||||
AGENT_ATTACH_BUTTON_CLICKED: 'app:agent_attach_button_clicked',
|
||||
AGENT_WORKFLOW_APPLIED: 'app:agent_workflow_applied',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
} as const
|
||||
@@ -864,13 +795,6 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterOpenedMetadata
|
||||
| HelpResourceClickedMetadata
|
||||
| HelpCenterClosedMetadata
|
||||
| AgentMessageFeedbackMetadata
|
||||
| AgentPanelOpenedMetadata
|
||||
| AgentPanelClosedMetadata
|
||||
| AgentEntryButtonClickedMetadata
|
||||
| AgentMessageSentMetadata
|
||||
| AgentNodeTaggedMetadata
|
||||
| AgentWorkflowAppliedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
|
||||
89
src/platform/workspace/api/partnerNodesApi.test.ts
Normal file
89
src/platform/workspace/api/partnerNodesApi.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockApiClient, mockGetAuthHeaderOrThrow } = vi.hoisted(() => ({
|
||||
mockApiClient: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn()
|
||||
},
|
||||
mockGetAuthHeaderOrThrow: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => mockApiClient)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/auth/unified/remintRetry', () => ({
|
||||
attachUnifiedRemintInterceptor: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
getAuthHeaderOrThrow: mockGetAuthHeaderOrThrow
|
||||
})
|
||||
}))
|
||||
|
||||
import { partnerNodesApi } from './partnerNodesApi'
|
||||
|
||||
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
|
||||
|
||||
describe('partnerNodesApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
|
||||
})
|
||||
|
||||
it('lists partner-node governance from the workspace resource', async () => {
|
||||
const response = { partner_nodes: [], auto_enable_new: false }
|
||||
mockApiClient.get.mockResolvedValue({ data: response })
|
||||
|
||||
await expect(partnerNodesApi.list()).resolves.toEqual(response)
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith(
|
||||
'/api/workspace/partner-nodes',
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
})
|
||||
|
||||
it('updates one node through the bulk mutation contract', async () => {
|
||||
mockApiClient.patch.mockResolvedValue({})
|
||||
|
||||
await partnerNodesApi.setEnabled('PartnerNode', false)
|
||||
|
||||
expect(mockApiClient.patch).toHaveBeenCalledWith(
|
||||
'/api/workspace/partner-nodes',
|
||||
{ node_ids: ['PartnerNode'], enabled: false },
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
})
|
||||
|
||||
it('updates a filtered set through the same resource', async () => {
|
||||
mockApiClient.patch.mockResolvedValue({})
|
||||
|
||||
await partnerNodesApi.setEnabledBulk(['NodeA', 'NodeB'], true)
|
||||
|
||||
expect(mockApiClient.patch).toHaveBeenCalledWith(
|
||||
'/api/workspace/partner-nodes',
|
||||
{ node_ids: ['NodeA', 'NodeB'], enabled: true },
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
})
|
||||
|
||||
it('updates the default for newly cataloged nodes', async () => {
|
||||
mockApiClient.patch.mockResolvedValue({})
|
||||
|
||||
await partnerNodesApi.setAutoEnableNew(true)
|
||||
|
||||
expect(mockApiClient.patch).toHaveBeenCalledWith(
|
||||
'/api/workspace/partner-nodes',
|
||||
{ auto_enable_new: true },
|
||||
{ headers: AUTH_HEADER }
|
||||
)
|
||||
})
|
||||
})
|
||||
86
src/platform/workspace/api/partnerNodesApi.ts
Normal file
86
src/platform/workspace/api/partnerNodesApi.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/** A partner (paid-API) node the workspace can allow or block. */
|
||||
export interface PartnerNode {
|
||||
/** Canonical Comfy node type ID; matches the /object_info object key. */
|
||||
id: string
|
||||
name: string
|
||||
partner: string
|
||||
/** ISO date of the last governance change, or null if never modified. */
|
||||
last_modified: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PartnerNodesResponse {
|
||||
partner_nodes: PartnerNode[]
|
||||
/** Workspace default applied to newly added partner nodes. */
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
interface BulkSetEnabledPayload {
|
||||
node_ids: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SetAutoEnablePayload {
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
const partnerNodesApiClient = axios.create({
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
attachUnifiedRemintInterceptor(partnerNodesApiClient)
|
||||
|
||||
async function authHeader() {
|
||||
return useAuthStore().getAuthHeaderOrThrow()
|
||||
}
|
||||
|
||||
export const partnerNodesApi = {
|
||||
/** Readable by every active workspace member. */
|
||||
async list(): Promise<PartnerNodesResponse> {
|
||||
const headers = await authHeader()
|
||||
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: BulkSetEnabledPayload = {
|
||||
node_ids: [nodeId],
|
||||
enabled
|
||||
}
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ export interface Member {
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
// Last time the member ran or interacted with the workspace, and the credits
|
||||
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
|
||||
// does not carry these fields yet.
|
||||
last_active_at?: string | null
|
||||
credits_used_this_month?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
| 'paused'
|
||||
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] size-4 text-amber-400" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
|
||||
<div
|
||||
v-else-if="isLoading && !subscription"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
|
||||
<div
|
||||
v-else-if="error && !subscription"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-text-secondary">
|
||||
<i class="pi pi-exclamation-circle text-danger" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div class="rounded-2xl border border-interface-stroke/60 p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planDisplayName = computed(() =>
|
||||
isInPersonalWorkspace.value
|
||||
? subscriptionTierName.value
|
||||
const planDisplayName = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return subscriptionTierName.value
|
||||
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
|
||||
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
|
||||
? t('subscription.enterprisePlanName')
|
||||
: t('subscription.teamPlanName')
|
||||
)
|
||||
})
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
|
||||
$attrs.class as string
|
||||
)
|
||||
"
|
||||
:style="imageUrl ? undefined : { background: gradient, textShadow }"
|
||||
>
|
||||
{{ letter }}
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="workspaceName"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<template v-else>{{ letter }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { workspaceName, imageUrl } = defineProps<{
|
||||
workspaceName: string
|
||||
imageUrl?: string
|
||||
}>()
|
||||
|
||||
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
|
||||
mockChangeMemberRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('shows promote copy and confirms with Make owner', async () => {
|
||||
it('shows promote copy and confirms with Make admin', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
expect(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user