Compare commits
13 Commits
feat/wire-
...
dproy/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3e24096b | ||
|
|
2de65d7e17 | ||
|
|
164d7aac4d | ||
|
|
76d453aaa3 | ||
|
|
9c65b47a64 | ||
|
|
c4c1c8121c | ||
|
|
58c076dd84 | ||
|
|
581b319b05 | ||
|
|
141615b911 | ||
|
|
31fac20a03 | ||
|
|
5e9abf2c41 | ||
|
|
309cbd4dc4 | ||
|
|
ad2ffdcd85 |
@@ -502,94 +502,11 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
## Files Generated
|
||||
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
|
||||
- \`post-release-checklist.md\` - Follow-up tasks
|
||||
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
|
||||
EOF
|
||||
```
|
||||
|
||||
4. **RELEASE COMPLETION**: All post-release setup completed?
|
||||
|
||||
### Step 16: Generate GTM Feature Summary
|
||||
|
||||
1. **Extract and analyze PR data:**
|
||||
```bash
|
||||
echo "📊 Checking for marketing-worthy features..."
|
||||
|
||||
# Extract all PR data inline
|
||||
PR_DATA=$(
|
||||
PR_LIST=$(git log ${BASE_TAG}..HEAD --grep="Merge pull request" --pretty=format:"%s" | grep -oE "#[0-9]+" | tr -d '#' | sort -u)
|
||||
|
||||
echo "["
|
||||
first=true
|
||||
for PR in $PR_LIST; do
|
||||
[[ "$first" == true ]] && first=false || echo ","
|
||||
gh pr view $PR --json number,title,author,body,files,labels,closedAt 2>/dev/null || continue
|
||||
done
|
||||
echo "]"
|
||||
)
|
||||
|
||||
# Save for analysis
|
||||
echo "$PR_DATA" > prs-${NEW_VERSION}.json
|
||||
```
|
||||
|
||||
2. **Analyze for GTM-worthy features:**
|
||||
```
|
||||
<task>
|
||||
Review these PRs to identify if ANY would interest a marketing/growth team.
|
||||
|
||||
Consider if a PR:
|
||||
- Changes something users directly interact with or experience
|
||||
- Makes something noticeably better, faster, or easier
|
||||
- Introduces capabilities users have been asking for
|
||||
- Has visual assets (screenshots, GIFs, videos) that could be shared
|
||||
- Tells a compelling story about improvement or innovation
|
||||
- Would make users excited if they heard about it
|
||||
|
||||
Many releases contain only technical improvements, bug fixes, or internal changes -
|
||||
that's perfectly normal. Only flag PRs that would genuinely interest end users.
|
||||
|
||||
If you find marketing-worthy PRs, note:
|
||||
- PR number, title, and author
|
||||
- Any media links from the description
|
||||
- One sentence on why it's worth showcasing
|
||||
|
||||
If nothing is marketing-worthy, just say "No marketing-worthy features in this release."
|
||||
</task>
|
||||
|
||||
PR data: [contents of prs-${NEW_VERSION}.json]
|
||||
```
|
||||
|
||||
3. **Generate GTM notification (only if needed):**
|
||||
```
|
||||
If there are marketing-worthy features, create a message for #gtm with:
|
||||
|
||||
🚀 Frontend Release v${NEW_VERSION}
|
||||
|
||||
Timeline: Available now in nightly, ~2-3 weeks for core
|
||||
|
||||
Features worth showcasing:
|
||||
[List the selected PRs with media links and authors]
|
||||
|
||||
Testing: --front-end-version ${NEW_VERSION}
|
||||
|
||||
If there are NO marketing-worthy features, generate:
|
||||
"No marketing-worthy features in v${NEW_VERSION} - mostly internal improvements and bug fixes."
|
||||
```
|
||||
|
||||
4. **Save the output:**
|
||||
```bash
|
||||
# Claude generates the GTM summary and saves it
|
||||
# Save to gtm-summary-${NEW_VERSION}.md
|
||||
|
||||
# Check if notification is needed
|
||||
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
|
||||
echo "✅ No GTM notification needed for this release"
|
||||
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
|
||||
else
|
||||
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
|
||||
echo "📤 Share this file in #gtm channel to notify the team"
|
||||
fi
|
||||
```
|
||||
|
||||
## Advanced Safety Features
|
||||
|
||||
### Rollback Procedures
|
||||
|
||||
@@ -49,7 +49,7 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
|
||||
## Development Guidelines
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use es-toolkit for utility functions
|
||||
2. Use lodash for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
4. Implement proper props and emits definitions
|
||||
5. Utilize Vue 3's Teleport component when needed
|
||||
|
||||
2
.github/copilot-instructions.md
vendored
@@ -18,7 +18,7 @@ Use Tailwind CSS for styling
|
||||
|
||||
Leverage VueUse functions for performance-enhancing styles
|
||||
|
||||
Use es-toolkit for utility functions
|
||||
Use lodash for utility functions
|
||||
|
||||
Use TypeScript for type safety
|
||||
|
||||
|
||||
5
.github/workflows/update-manager-types.yaml
vendored
@@ -61,11 +61,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint generated types
|
||||
run: |
|
||||
echo "Linting generated ComfyUI-Manager API types..."
|
||||
npm run lint:fix:no-cache -- ./src/types/generatedManagerTypes.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
|
||||
5
.github/workflows/update-registry-types.yaml
vendored
@@ -61,11 +61,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint generated types
|
||||
run: |
|
||||
echo "Linting generated Comfy Registry API types..."
|
||||
npm run lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
|
||||
14
.gitignore
vendored
@@ -7,15 +7,6 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Package manager lockfiles (allow users to use different package managers)
|
||||
bun.lock
|
||||
bun.lockb
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
@@ -67,8 +58,5 @@ dist.zip
|
||||
# Temporary repository directory
|
||||
templates_repo/
|
||||
|
||||
# Vite's timestamped config modules
|
||||
# Vite’s timestamped config modules
|
||||
vite.config.mts.timestamp-*.mjs
|
||||
|
||||
# Linux core dumps
|
||||
./core
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Topbar {
|
||||
workflowName: string,
|
||||
command: 'Save' | 'Save As' | 'Export'
|
||||
) {
|
||||
await this.triggerTopbarCommand(['File', command])
|
||||
await this.triggerTopbarCommand(['Workflow', command])
|
||||
await this.getSaveDialog().fill(workflowName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -72,8 +72,8 @@ export class Topbar {
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 1) {
|
||||
throw new Error('Path cannot be empty')
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const menu = await this.openTopbarMenu()
|
||||
@@ -85,13 +85,6 @@ export class Topbar {
|
||||
.locator('.p-tieredmenu-item')
|
||||
.filter({ has: topLevelMenuItem })
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
|
||||
// Handle top-level commands (like "New")
|
||||
if (path.length === 1) {
|
||||
await topLevelMenuItem.click()
|
||||
return
|
||||
}
|
||||
|
||||
await topLevelMenu.hover()
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import fs from 'fs'
|
||||
import _ from 'lodash'
|
||||
import path from 'path'
|
||||
import type { Request, Route } from 'playwright'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -34,17 +34,23 @@ const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||
}
|
||||
|
||||
const setQueueIndex = (task: TaskItem) => {
|
||||
task.prompt[0] = TaskHistory.queueIndex++
|
||||
task.prompt.priority = TaskHistory.queueIndex++
|
||||
}
|
||||
|
||||
const setPromptId = (task: TaskItem) => {
|
||||
task.prompt[1] = uuidv4()
|
||||
if (!task.prompt.prompt_id || task.prompt.prompt_id === 'prompt-id') {
|
||||
task.prompt.prompt_id = uuidv4()
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaskHistory {
|
||||
static queueIndex = 0
|
||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: uuidv4() }
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
@@ -66,18 +72,43 @@ export default class TaskHistory {
|
||||
)
|
||||
|
||||
private async handleGetHistory(route: Route) {
|
||||
const url = route.request().url()
|
||||
|
||||
// Handle history_v2/:prompt_id endpoint
|
||||
const promptIdMatch = url.match(/history_v2\/([^?]+)/)
|
||||
if (promptIdMatch) {
|
||||
const promptId = promptIdMatch[1]
|
||||
const task = this.tasks.find((t) => t.prompt.prompt_id === promptId)
|
||||
const response: Record<string, any> = {}
|
||||
if (task) {
|
||||
response[promptId] = task
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle history_v2 list endpoint
|
||||
// Convert HistoryTaskItem to RawHistoryItem format expected by API
|
||||
const rawHistoryItems = this.tasks.map((task) => ({
|
||||
prompt_id: task.prompt.prompt_id,
|
||||
prompt: task.prompt,
|
||||
status: task.status,
|
||||
outputs: task.outputs,
|
||||
...(task.meta && { meta: task.meta })
|
||||
}))
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.tasks)
|
||||
body: JSON.stringify({ history: rawHistoryItems })
|
||||
})
|
||||
}
|
||||
|
||||
private async handleGetView(route: Route) {
|
||||
const fileName = getFilenameParam(route.request())
|
||||
if (!this.outputContentTypes.has(fileName)) {
|
||||
return route.continue()
|
||||
}
|
||||
if (!this.outputContentTypes.has(fileName)) return route.continue()
|
||||
|
||||
const asset = this.loadAsset(fileName)
|
||||
return route.fulfill({
|
||||
@@ -93,7 +124,7 @@ export default class TaskHistory {
|
||||
|
||||
async setupRoutes() {
|
||||
return this.comfyPage.page.route(
|
||||
/.*\/api\/(view|history)(\?.*)?$/,
|
||||
/.*\/api\/(view|history_v2)(\/[^?]*)?(\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
|
||||
@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
test.skip('Can be added to canvas using node library sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
test.skip('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const makeGroup = async (name, type1, type2) => {
|
||||
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
|
||||
expect(visibleInputCount).toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
@@ -268,7 +268,10 @@ test.describe('Group Node', () => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
// Clear workflow
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand([
|
||||
'Edit',
|
||||
'Clear Workflow'
|
||||
])
|
||||
|
||||
await comfyPage.ctrlV()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
@@ -277,7 +280,7 @@ test.describe('Group Node', () => {
|
||||
test('Copies and pastes group node into a newly created blank workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.ctrlV()
|
||||
await verifyNodeLoaded(comfyPage, 1)
|
||||
})
|
||||
@@ -293,7 +296,7 @@ test.describe('Group Node', () => {
|
||||
test('Serializes group node after copy and paste across workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.ctrlV()
|
||||
const currentGraphState = await comfyPage.page.evaluate(() =>
|
||||
window['app'].graph.serialize()
|
||||
|
||||
131
browser_tests/tests/historyApi.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('History API v2', () => {
|
||||
const TEST_PROMPT_ID = 'test-prompt-id'
|
||||
const TEST_CLIENT_ID = 'test-client'
|
||||
|
||||
test('Can fetch history with new v2 format', async ({ comfyPage }) => {
|
||||
// Set up mocked history with tasks
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
|
||||
// Verify history_v2 API response format
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const response = await window['app'].api.getHistory()
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveProperty('History')
|
||||
expect(Array.isArray(result.data.History)).toBe(true)
|
||||
expect(result.data.History.length).toBeGreaterThan(0)
|
||||
|
||||
const historyItem = result.data.History[0]
|
||||
|
||||
// Verify the new prompt structure (object instead of array)
|
||||
expect(historyItem.prompt).toHaveProperty('priority')
|
||||
expect(historyItem.prompt).toHaveProperty('prompt_id')
|
||||
expect(historyItem.prompt).toHaveProperty('extra_data')
|
||||
expect(typeof historyItem.prompt.priority).toBe('number')
|
||||
expect(typeof historyItem.prompt.prompt_id).toBe('string')
|
||||
expect(historyItem.prompt.extra_data).toHaveProperty('client_id')
|
||||
})
|
||||
|
||||
test('Can load workflow from history using history_v2 endpoint', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Simple mock workflow for testing
|
||||
const mockWorkflow = {
|
||||
version: 0.4,
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [100, 100], size: [200, 100] }],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
}
|
||||
|
||||
// Set up history with workflow data
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'], 'images', {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: TEST_PROMPT_ID,
|
||||
extra_data: {
|
||||
client_id: TEST_CLIENT_ID,
|
||||
extra_pnginfo: { workflow: mockWorkflow }
|
||||
}
|
||||
}
|
||||
})
|
||||
.setupRoutes()
|
||||
|
||||
// Load initial workflow to clear canvas
|
||||
await comfyPage.loadWorkflow('simple_slider')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Load workflow from history
|
||||
const loadResult = await comfyPage.page.evaluate(async (promptId) => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory(promptId)
|
||||
if (workflow) {
|
||||
await window['app'].loadGraphData(workflow)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: 'No workflow found' }
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflow from history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}, TEST_PROMPT_ID)
|
||||
|
||||
expect(loadResult.success).toBe(true)
|
||||
|
||||
// Verify workflow loaded correctly
|
||||
await comfyPage.nextFrame()
|
||||
const nodeInfo = await comfyPage.page.evaluate(() => {
|
||||
try {
|
||||
const graph = window['app'].graph
|
||||
return {
|
||||
success: true,
|
||||
nodeCount: graph.nodes?.length || 0,
|
||||
firstNodeType: graph.nodes?.[0]?.type || null
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(nodeInfo.success).toBe(true)
|
||||
expect(nodeInfo.nodeCount).toBe(1)
|
||||
expect(nodeInfo.firstNodeType).toBe('TestNode')
|
||||
})
|
||||
|
||||
test('Handles missing workflow data gracefully', async ({ comfyPage }) => {
|
||||
// Set up empty history routes
|
||||
await comfyPage.setupHistory().setupRoutes()
|
||||
|
||||
// Test loading from history with invalid prompt_id
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory('invalid-id')
|
||||
return { success: true, workflow }
|
||||
} catch (error) {
|
||||
console.error('Expected error for invalid prompt_id:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Should handle gracefully without throwing
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.workflow).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
|
||||
workflowA = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowA)
|
||||
workflowB = generateUniqueFilename()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowB)
|
||||
|
||||
// Wait for localStorage to persist the workflow paths before reloading
|
||||
|
||||
@@ -75,7 +75,7 @@ test.describe('Menu', () => {
|
||||
|
||||
test('Displays keybinding next to item', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
|
||||
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
|
||||
await workflowMenuItem.hover()
|
||||
const exportTag = comfyPage.page.locator('.keybinding-tag', {
|
||||
hasText: 'Ctrl + s'
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
|
||||
[workflowName]: workflowName
|
||||
})
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
|
||||
// Insert the workflow
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
|
||||
@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
test.skip('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.rightClickCanvas()
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node')
|
||||
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -187,12 +187,14 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
@@ -317,25 +319,6 @@ test.describe('Workflows sidebar', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('Can duplicate workflow from context menu', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
})
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
|
||||
await workflowsTab
|
||||
.getPersistedItem('workflow1.json')
|
||||
.click({ button: 'right' })
|
||||
await comfyPage.clickContextMenuItem('Duplicate')
|
||||
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'*workflow1 (Copy).json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
|
||||
@@ -63,7 +63,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
test('Should show thumbnail when hovering over a non-active tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
0,
|
||||
@@ -73,7 +73,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
})
|
||||
|
||||
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
const thumbnailImg = await getTabThumbnailImage(
|
||||
comfyPage,
|
||||
1,
|
||||
@@ -105,7 +105,7 @@ test.describe('Workflow Tab Thumbnails', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Create a new workflow (tab 1) which will be empty
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)
|
||||
|
||||
@@ -14,10 +14,7 @@ export default [
|
||||
ignores: [
|
||||
'src/scripts/*',
|
||||
'src/extensions/core/*',
|
||||
'src/types/vue-shim.d.ts',
|
||||
// Generated files that don't need linting
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
'src/types/generatedManagerTypes.ts'
|
||||
'src/types/vue-shim.d.ts'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
25
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.3",
|
||||
"version": "1.26.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.26.3",
|
||||
"version": "1.26.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -30,12 +30,12 @@
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -62,6 +62,7 @@
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/three": "^0.169.0",
|
||||
@@ -4886,6 +4887,12 @@
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz",
|
||||
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
@@ -7991,15 +7998,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.9",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz",
|
||||
"integrity": "sha512-9OtbkZmTA2Qc9groyA1PUNeb6knVTkvB2RSdr/LcJXDL8IdEakaxwXLHXa7VX/Wj0GmdMJPR3WhnPGhiP3E+qg==",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -11452,8 +11450,7 @@
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
|
||||
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.26.3",
|
||||
"version": "1.26.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -21,10 +21,8 @@
|
||||
"test:component": "vitest run src/components/",
|
||||
"prepare": "husky || true",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"knip": "knip",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
|
||||
@@ -41,6 +39,7 @@
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/three": "^0.169.0",
|
||||
@@ -99,12 +98,12 @@
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"es-toolkit": "^1.39.9",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "^2.1.7",
|
||||
|
||||
@@ -51,7 +51,7 @@ const template = await fetch('/templates/default.json')
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use es-toolkit for utility functions
|
||||
- Use lodash for utility functions
|
||||
- Implement proper TypeScript types
|
||||
- Follow Vue 3 composition API style guide
|
||||
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.91396 12.7428L5.41396 10.7428C5.57175 10.1116 5.09439 9.50024 4.44382 9.50024H2.50538C2.04651 9.50024 1.64652 9.81253 1.53523 10.2577L1.03523 12.2577C0.877446 12.8888 1.3548 13.5002 2.00538 13.5002H3.94382C4.40269 13.5002 4.80267 13.1879 4.91396 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M5.91396 6.74277L6.41396 4.74277C6.57175 4.11163 6.09439 3.50024 5.44382 3.50024H3.50538C3.04651 3.50024 2.64652 3.81253 2.53523 4.2577L2.03523 6.2577C1.87745 6.88885 2.3548 7.50024 3.00538 7.50024H4.94382C5.40269 7.50024 5.80267 7.18794 5.91396 6.74277Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M10.914 12.7428L11.414 10.7428C11.5718 10.1116 11.0944 9.50024 10.4438 9.50024H8.50538C8.04651 9.50024 7.64652 9.81253 7.53523 10.2577L7.03523 12.2577C6.87745 12.8888 7.3548 13.5002 8.00538 13.5002H9.94382C10.4027 13.5002 10.8027 13.1879 10.914 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M12.2342 5.46739L11.5287 7.11354C11.4248 7.35597 11.0811 7.35597 10.9772 7.11354L10.2717 5.46739C10.2414 5.39659 10.185 5.34017 10.1141 5.30983L8.468 4.60433C8.22557 4.50044 8.22557 4.15675 8.468 4.05285L10.1141 3.34736C10.185 3.31701 10.2414 3.26059 10.2717 3.18979L10.9772 1.54364C11.0811 1.30121 11.4248 1.30121 11.5287 1.54364L12.2342 3.18979C12.2645 3.26059 12.3209 3.31701 12.3918 3.34736L14.0379 4.05285C14.2803 4.15675 14.2803 4.50044 14.0379 4.60433L12.3918 5.30983C12.3209 5.34017 12.2645 5.39659 12.2342 5.46739Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 10L10.598 10.2577C10.4812 10.6954 10.0848 11 9.63172 11H5.30161C4.64458 11 4.16608 10.3772 4.33538 9.74234L5.40204 5.74234C5.51878 5.30458 5.91523 5 6.36828 5H10.8286C11.4199 5 11.8505 5.56051 11.6982 6.13185L11.6736 6.22389M14 8H10M4.5 8H2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 405 B |
@@ -1,5 +0,0 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.1894 6.24254L13.6894 4.24254C13.8471 3.61139 13.3698 3 12.7192 3H3.78077C3.3219 3 2.92192 3.3123 2.81062 3.75746L2.31062 5.75746C2.15284 6.38861 2.63019 7 3.28077 7H12.2192C12.6781 7 13.0781 6.6877 13.1894 6.24254Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M13.1894 12.2425L13.6894 10.2425C13.8471 9.61139 13.3698 9 12.7192 9H8.78077C8.3219 9 7.92192 9.3123 7.81062 9.75746L7.31062 11.7575C7.15284 12.3886 7.6302 13 8.28077 13H12.2192C12.6781 13 13.0781 12.6877 13.1894 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<path d="M5.18936 12.2425L5.68936 10.2425C5.84714 9.61139 5.36978 9 4.71921 9H3.78077C3.3219 9 2.92192 9.3123 2.81062 9.75746L2.31062 11.7575C2.15284 12.3886 2.6302 13 3.28077 13H4.21921C4.67808 13 5.07806 12.6877 5.18936 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 970 B |
@@ -20,7 +20,7 @@ import {
|
||||
useLocalStorage,
|
||||
watchDebounced
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
import Panel from 'primevue/panel'
|
||||
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="TFilter extends SearchFilter">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { debounce } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useElementSize, useScroll, whenever } from '@vueuse/core'
|
||||
import { clamp, debounce } from 'es-toolkit/compat'
|
||||
import { clamp, debounce } from 'lodash'
|
||||
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
type GridState = {
|
||||
|
||||
@@ -169,8 +169,8 @@ import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { cloneDeep } from 'es-toolkit/compat'
|
||||
import _ from 'lodash'
|
||||
import cloneDeep from 'lodash/cloneDeep'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import {
|
||||
computed,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import PackEnableToggle from './PackEnableToggle.vue'
|
||||
|
||||
// Mock debounce to execute immediately
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
vi.mock('lodash', () => ({
|
||||
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
|
||||
}))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { debounce } from 'lodash'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { stubTrue } from 'es-toolkit/compat'
|
||||
import { stubTrue } from 'lodash'
|
||||
import AutoComplete, {
|
||||
AutoCompleteOptionSelectEvent
|
||||
} from 'primevue/autocomplete'
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
// Global variable from vite build defined in global.d.ts
|
||||
// eslint-disable-next-line no-undef
|
||||
const isStaging = !__USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from '@/config/environment'
|
||||
|
||||
const isStaging = !isProductionEnvironment()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="isUnpackVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:expand />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isConvertVisible"
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
@@ -34,7 +20,6 @@ import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
@@ -42,13 +27,7 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isUnpackVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.selectedItems?.length === 1 &&
|
||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||
)
|
||||
})
|
||||
const isConvertVisible = computed(() => {
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
|
||||
@@ -82,7 +82,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import _ from 'es-toolkit/compat'
|
||||
import _ from 'lodash'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
:icon-badge="tab.iconBadge"
|
||||
:tooltip="tab.tooltip"
|
||||
:tooltip-suffix="getTabTooltipSuffix(tab)"
|
||||
:label="tab.label || tab.title"
|
||||
:is-small="isSmall"
|
||||
:selected="tab.id === selectedTab?.id"
|
||||
:class="tab.id + '-tab-button'"
|
||||
@click="onTabClick(tab)"
|
||||
/>
|
||||
<SidebarTemplatesButton />
|
||||
<div class="side-tool-bar-end">
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarHelpCenterIcon />
|
||||
@@ -46,7 +43,6 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -90,7 +86,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
box-shadow: var(--bar-shadow);
|
||||
|
||||
--sidebar-width: 4rem;
|
||||
--sidebar-icon-size: 1rem;
|
||||
--sidebar-icon-size: 1.5rem;
|
||||
}
|
||||
|
||||
.side-tool-bar-container.small-sidebar {
|
||||
|
||||
@@ -58,12 +58,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
@@ -71,9 +70,8 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { shouldShowRedDot } = storeToRefs(releaseStore)
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const isHelpCenterVisible = ref(false)
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -82,11 +80,11 @@ const sidebarLocation = computed(() =>
|
||||
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
|
||||
|
||||
const toggleHelpCenter = () => {
|
||||
helpCenterStore.toggle()
|
||||
isHelpCenterVisible.value = !isHelpCenterVisible.value
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
helpCenterStore.hide()
|
||||
isHelpCenterVisible.value = false
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
@@ -132,7 +130,6 @@ onMounted(async () => {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
@@ -19,29 +19,12 @@
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="side-bar-button-content">
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i
|
||||
v-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component :is="icon" v-else class="side-bar-button-icon" />
|
||||
</OverlayBadge>
|
||||
<i
|
||||
v-else-if="typeof icon === 'string'"
|
||||
:class="icon + ' side-bar-button-icon'"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
v-else-if="typeof icon === 'object'"
|
||||
class="side-bar-button-icon"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
t(label)
|
||||
}}</span>
|
||||
</div>
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i :class="icon + ' side-bar-button-icon'" />
|
||||
</OverlayBadge>
|
||||
<i v-else :class="icon + ' side-bar-button-icon'" />
|
||||
</slot>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -50,7 +33,6 @@
|
||||
import Button from 'primevue/button'
|
||||
import OverlayBadge from 'primevue/overlaybadge'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -59,17 +41,13 @@ const {
|
||||
selected = false,
|
||||
tooltip = '',
|
||||
tooltipSuffix = '',
|
||||
iconBadge = '',
|
||||
label = '',
|
||||
isSmall = false
|
||||
iconBadge = ''
|
||||
} = defineProps<{
|
||||
icon?: string | Component
|
||||
icon?: string
|
||||
selected?: boolean
|
||||
tooltip?: string
|
||||
tooltipSuffix?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
label?: string
|
||||
isSmall?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -96,21 +74,8 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
<style scoped>
|
||||
.side-bar-button {
|
||||
width: var(--sidebar-width);
|
||||
height: calc(var(--sidebar-width) + 0.5rem);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.side-tool-bar-end .side-bar-button {
|
||||
height: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.side-bar-button-content {
|
||||
@apply flex flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.side-bar-button-label {
|
||||
@apply text-[10px] text-center whitespace-nowrap;
|
||||
line-height: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.comfyui-body-left .side-bar-button.side-bar-button-selected,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:icon="TemplateIcon"
|
||||
:tooltip="$t('sideToolbar.templates')"
|
||||
:label="$t('sideToolbar.labels.templates')"
|
||||
:is-small="isSmall"
|
||||
class="templates-tab-button"
|
||||
@click="openTemplates"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
// Import the custom template icon
|
||||
const TemplateIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
)
|
||||
|
||||
const openTemplates = () => {
|
||||
void commandStore.execute('Comfy.BrowseTemplates')
|
||||
}
|
||||
</script>
|
||||
@@ -30,17 +30,10 @@
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-filter-slash"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('menu.refresh')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
@@ -146,7 +139,6 @@ import {
|
||||
DEFAULT_SORTING_ID,
|
||||
nodeOrganizationService
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
@@ -163,7 +155,6 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const commandStore = useCommandStore()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { ComfyNode } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ResultItemImpl,
|
||||
@@ -126,6 +126,7 @@ const toast = useToast()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Expanded view: show all outputs in a flat list.
|
||||
@@ -208,8 +209,16 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
{
|
||||
label: t('g.loadWorkflow'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: () => menuTargetTask.value?.loadWorkflow(app),
|
||||
disabled: !menuTargetTask.value?.workflow
|
||||
command: () => {
|
||||
if (menuTargetTask.value) {
|
||||
void workflowService.loadTaskWorkflow(menuTargetTask.value)
|
||||
}
|
||||
},
|
||||
disabled: !(
|
||||
menuTargetTask.value?.workflow ||
|
||||
(menuTargetTask.value?.isHistory &&
|
||||
menuTargetTask.value?.prompt.prompt_id)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: t('g.goToNode'),
|
||||
|
||||
@@ -265,14 +265,6 @@ const renderTreeNode = (
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('g.duplicate'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.duplicateWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -55,30 +55,9 @@
|
||||
v-bind="props.action"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
:class="typeof item.class === 'function' ? item.class() : item.class"
|
||||
@mousedown="
|
||||
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
|
||||
"
|
||||
@click="isZoomCommand(item) ? handleZoomClick($event) : undefined"
|
||||
>
|
||||
<i
|
||||
v-if="hasActiveStateSiblings(item)"
|
||||
class="p-menubar-item-icon pi pi-check text-sm"
|
||||
:class="{ invisible: !item.comfyCommand?.active?.() }"
|
||||
/>
|
||||
<span
|
||||
v-else-if="
|
||||
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
|
||||
"
|
||||
class="p-menubar-item-icon"
|
||||
:class="item.icon"
|
||||
/>
|
||||
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<i
|
||||
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
|
||||
class="ml-auto"
|
||||
:class="item.icon"
|
||||
/>
|
||||
<span
|
||||
v-if="item?.comfyCommand?.keybinding"
|
||||
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
|
||||
@@ -115,7 +94,6 @@ import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
@@ -185,22 +163,16 @@ const extraMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'browse-templates',
|
||||
label: t('menuLabels.Browse Templates'),
|
||||
icon: 'pi pi-folder-open',
|
||||
command: () => commandStore.execute('Comfy.BrowseTemplates')
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
command: () => showSettings()
|
||||
},
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
}
|
||||
]
|
||||
|
||||
@@ -265,36 +237,6 @@ const onMenuShow = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isZoomCommand = (item: MenuItem) => {
|
||||
return (
|
||||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
|
||||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
|
||||
)
|
||||
}
|
||||
|
||||
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
|
||||
if (item.comfyCommand) {
|
||||
whileMouseDown(
|
||||
event,
|
||||
async () => {
|
||||
await commandStore.execute(item.comfyCommand!.id)
|
||||
},
|
||||
50
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleZoomClick = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Prevent the menu from closing for zoom commands
|
||||
return false
|
||||
}
|
||||
|
||||
const hasActiveStateSiblings = (item: MenuItem): boolean => {
|
||||
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { debounce } from 'lodash'
|
||||
import { Ref, markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { debounce } from 'lodash'
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
|
||||
@@ -261,10 +261,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.14-2.80/Run (varies with model, mode & duration)'
|
||||
|
||||
const modelValue = String(modelWidget.value)
|
||||
if (
|
||||
modelValue.includes('v2-1-master') ||
|
||||
modelValue.includes('v2-master')
|
||||
) {
|
||||
if (modelValue.includes('v2-master')) {
|
||||
return '$1.40/Run'
|
||||
} else if (
|
||||
modelValue.includes('v1-6') ||
|
||||
@@ -283,19 +280,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
console.log('durationValue', durationValue)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (
|
||||
modelValue.includes('v2-1-master') ||
|
||||
modelValue.includes('v2-master')
|
||||
) {
|
||||
if (modelValue.includes('v2-master')) {
|
||||
if (durationValue.includes('10')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
return '$1.40/Run' // 5s default
|
||||
} else if (
|
||||
modelValue.includes('v2-1') ||
|
||||
modelValue.includes('v1-6') ||
|
||||
modelValue.includes('v1-5')
|
||||
) {
|
||||
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
|
||||
if (modeValue.includes('pro')) {
|
||||
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
|
||||
} else {
|
||||
@@ -428,12 +418,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Pricing matrix from CSV data based on mode string content
|
||||
if (modeValue.includes('v2-1-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run' // price is the same as for v2-master model
|
||||
}
|
||||
return '$1.40/Run' // price is the same as for v2-master model
|
||||
} else if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
@@ -573,32 +558,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
MinimaxTextToVideoNode: {
|
||||
displayPrice: '$0.43/Run'
|
||||
},
|
||||
MinimaxHailuoVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget || !durationWidget) {
|
||||
return '$0.28-0.56/Run (varies with resolution & duration)'
|
||||
}
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
const duration = String(durationWidget.value)
|
||||
|
||||
if (resolution.includes('768P')) {
|
||||
if (duration.includes('6')) return '$0.28/Run'
|
||||
if (duration.includes('10')) return '$0.56/Run'
|
||||
} else if (resolution.includes('1080P')) {
|
||||
if (duration.includes('6')) return '$0.49/Run'
|
||||
}
|
||||
|
||||
return '$0.43/Run' // default median
|
||||
}
|
||||
},
|
||||
OpenAIDalle2: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const sizeWidget = node.widgets?.find(
|
||||
@@ -1319,13 +1278,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return '$0.5/second'
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return '$0.0003/$0.0025 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return '$0.0003/$0.0025 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return '$0.00016/$0.0006 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -1403,7 +1358,6 @@ export const useNodePricing = () => {
|
||||
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
|
||||
KlingSingleImageVideoEffectNode: ['effect_scene'],
|
||||
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
|
||||
MinimaxHailuoVideoNode: ['resolution', 'duration'],
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import { groupBy } from 'lodash'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const AiModelIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/ai-model'))
|
||||
)
|
||||
|
||||
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'model-library',
|
||||
icon: AiModelIcon,
|
||||
icon: 'pi pi-box',
|
||||
title: 'sideToolbar.modelLibrary',
|
||||
tooltip: 'sideToolbar.modelLibrary',
|
||||
label: 'sideToolbar.labels.models',
|
||||
component: markRaw(ModelLibrarySidebarTab),
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
const NodeIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/node'))
|
||||
)
|
||||
|
||||
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'node-library',
|
||||
icon: NodeIcon,
|
||||
icon: 'pi pi-book',
|
||||
title: 'sideToolbar.nodeLibrary',
|
||||
tooltip: 'sideToolbar.nodeLibrary',
|
||||
label: 'sideToolbar.labels.nodes',
|
||||
component: markRaw(NodeLibrarySidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
|
||||
},
|
||||
title: 'sideToolbar.queue',
|
||||
tooltip: 'sideToolbar.queue',
|
||||
label: 'sideToolbar.labels.queue',
|
||||
component: markRaw(QueueSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
const WorkflowIcon = markRaw(
|
||||
defineAsyncComponent(() => import('virtual:icons/comfy/workflow'))
|
||||
)
|
||||
|
||||
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
id: 'workflows',
|
||||
icon: WorkflowIcon,
|
||||
icon: 'pi pi-folder-open',
|
||||
iconBadge: () => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
|
||||
@@ -26,7 +22,6 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
},
|
||||
title: 'sideToolbar.workflows',
|
||||
tooltip: 'sideToolbar.workflows',
|
||||
label: 'sideToolbar.labels.workflows',
|
||||
component: markRaw(WorkflowsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
@@ -279,7 +277,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
label: 'Fit view to selected nodes',
|
||||
menubarLabel: 'Zoom to fit',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
@@ -305,7 +302,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.Canvas.ToggleLinkVisibility',
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Canvas Toggle Link Visibility',
|
||||
menubarLabel: 'Node Links',
|
||||
versionAdded: '1.3.6',
|
||||
|
||||
function: (() => {
|
||||
@@ -327,15 +323,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
)
|
||||
}
|
||||
}
|
||||
})(),
|
||||
active: () =>
|
||||
useSettingStore().get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK
|
||||
})()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.ToggleMinimap',
|
||||
icon: 'pi pi-map',
|
||||
label: 'Canvas Toggle Minimap',
|
||||
menubarLabel: 'Minimap',
|
||||
versionAdded: '1.24.1',
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
@@ -343,8 +336,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
'Comfy.Minimap.Visible',
|
||||
!settingStore.get('Comfy.Minimap.Visible')
|
||||
)
|
||||
},
|
||||
active: () => useSettingStore().get('Comfy.Minimap.Visible')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.QueuePrompt',
|
||||
@@ -548,25 +540,21 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Workspace.ToggleBottomPanel',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
menubarLabel: 'Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
bottomPanelStore.toggleBottomPanel()
|
||||
},
|
||||
active: () => bottomPanelStore.bottomPanelVisible
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleFocusMode',
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
menubarLabel: 'Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
},
|
||||
active: () => useWorkspaceStore().focusMode
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.FitGroupToContents',
|
||||
@@ -807,53 +795,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.UnpackSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Unpack the selected Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const subgraphNode = app.canvas.selectedItems.values().next().value
|
||||
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.OpenManagerDialog',
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
label: 'Manager',
|
||||
function: () => {
|
||||
dialogService.showManagerDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleHelpCenter',
|
||||
icon: 'pi pi-question-circle',
|
||||
label: 'Help Center',
|
||||
function: () => {
|
||||
useHelpCenterStore().toggle()
|
||||
},
|
||||
active: () => useHelpCenterStore().isVisible
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleCanvasInfo',
|
||||
icon: 'pi pi-info-circle',
|
||||
label: 'Canvas Performance',
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const currentValue = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
await settingStore.set('Comfy.Graph.CanvasInfo', !currentValue)
|
||||
},
|
||||
active: () => useSettingStore().get('Comfy.Graph.CanvasInfo')
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel.Shortcuts',
|
||||
icon: 'pi pi-key',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { whenever } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useCivitaiModel } from '@/composables/useCivitaiModel'
|
||||
import { fetchWithHeaders } from '@/services/networkClientAdapter'
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
|
||||
export function useDownload(url: string, fileName?: string) {
|
||||
@@ -15,7 +14,7 @@ export function useDownload(url: string, fileName?: string) {
|
||||
|
||||
const fetchFileSize = async () => {
|
||||
try {
|
||||
const response = await fetchWithHeaders(url, { method: 'HEAD' })
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) throw new Error('Failed to fetch file size')
|
||||
|
||||
const size = response.headers.get('content-length')
|
||||
|
||||
@@ -19,7 +19,6 @@ export const useLitegraphSettings = () => {
|
||||
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.show_info = canvasInfoEnabled
|
||||
canvasStore.canvas.draw(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -102,9 +102,6 @@ export function useMinimap() {
|
||||
const groupColor = computed(() =>
|
||||
isLightTheme.value ? '#A2D3EC' : '#1F547A'
|
||||
)
|
||||
const groupColorDefault = computed(
|
||||
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
|
||||
)
|
||||
const bypassColor = computed(() =>
|
||||
isLightTheme.value ? '#DBDBDB' : '#4B184B'
|
||||
)
|
||||
@@ -252,17 +249,7 @@ export function useMinimap() {
|
||||
const w = group.size[0] * scale.value
|
||||
const h = group.size[1] * scale.value
|
||||
|
||||
let color = groupColor.value
|
||||
|
||||
if (nodeColors.value) {
|
||||
color = group.color ?? groupColorDefault.value
|
||||
|
||||
if (isLightTheme.value) {
|
||||
color = adjustColor(color, { opacity: 0.5 })
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillStyle = groupColor.value
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { orderBy } from 'es-toolkit/compat'
|
||||
import { orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { fetchWithHeaders } from '@/services/networkClientAdapter'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import type {
|
||||
@@ -161,17 +160,12 @@ export function useTemplateWorkflows() {
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served as static files
|
||||
const response = await fetchWithHeaders(
|
||||
api.fileURL(`/templates/${id}.json`)
|
||||
)
|
||||
return await response.json()
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
// Custom node templates served via API
|
||||
const response = await api.fetchApi(
|
||||
`/workflow_templates/${sourceModule}/${id}.json`
|
||||
)
|
||||
return await response.json()
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -89,11 +88,6 @@ export function useWorkflowPersistence() {
|
||||
)
|
||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||
|
||||
// Clean up event listener when component unmounts
|
||||
tryOnScopeDispose(() => {
|
||||
api.removeEventListener('graphChanged', persistCurrentWorkflow)
|
||||
})
|
||||
|
||||
// Restore workflow tabs states
|
||||
const openWorkflows = computed(() => workflowStore.openWorkflows)
|
||||
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import _ from 'lodash'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
|
||||
// Create axios client with header injection
|
||||
const axiosClient = createAxiosWithHeaders()
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T
|
||||
timestamp?: number
|
||||
@@ -60,10 +59,21 @@ const fetchData = async (
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
const res = await axiosClient.get(route, {
|
||||
|
||||
// Get auth header from Firebase
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (authHeader) {
|
||||
Object.assign(headers, authHeader)
|
||||
}
|
||||
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout
|
||||
timeout,
|
||||
headers
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
export const COMFY_API_BASE_URL = isProductionEnvironment()
|
||||
? 'https://api.comfy.org'
|
||||
: 'https://stagingapi.comfy.org'
|
||||
|
||||
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
export const COMFY_PLATFORM_BASE_URL = isProductionEnvironment()
|
||||
? 'https://platform.comfy.org'
|
||||
: 'https://stagingplatform.comfy.org'
|
||||
|
||||
18
src/config/environment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Runtime environment configuration that determines if we're in production or staging
|
||||
* based on the hostname. Replaces the build-time __USE_PROD_CONFIG__ constant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the application is running in production environment
|
||||
* @returns true if hostname is cloud.comfy.org (production), false otherwise (staging)
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
// In SSR/Node.js environments or during build, use the environment variable
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.USE_PROD_CONFIG === 'true'
|
||||
}
|
||||
|
||||
// In browser, check the hostname
|
||||
return window.location.hostname === 'cloud.comfy.org'
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
authDomain: 'dreamboothy-dev.firebaseapp.com',
|
||||
@@ -23,6 +25,6 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
}
|
||||
|
||||
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = isProductionEnvironment()
|
||||
? PROD_CONFIG
|
||||
: DEV_CONFIG
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export const CORE_MENU_COMMANDS = [
|
||||
[[], ['Comfy.NewBlankWorkflow']],
|
||||
[[], []], // Separator after New
|
||||
[['File'], ['Comfy.OpenWorkflow']],
|
||||
[['Workflow'], ['Comfy.NewBlankWorkflow']],
|
||||
[['Workflow'], ['Comfy.OpenWorkflow', 'Comfy.BrowseTemplates']],
|
||||
[
|
||||
['File'],
|
||||
['Workflow'],
|
||||
[
|
||||
'Comfy.SaveWorkflow',
|
||||
'Comfy.SaveWorkflowAs',
|
||||
@@ -12,6 +11,8 @@ export const CORE_MENU_COMMANDS = [
|
||||
]
|
||||
],
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[
|
||||
['Help'],
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Example showing how authentication headers are automatically injected
|
||||
* with the new header registration system.
|
||||
*
|
||||
* Before: Services had to manually retrieve and add auth headers
|
||||
* After: Headers are automatically injected via the network adapters
|
||||
*/
|
||||
import {
|
||||
createAxiosWithHeaders,
|
||||
fetchWithHeaders
|
||||
} from '@/services/networkClientAdapter'
|
||||
|
||||
// ============================================
|
||||
// BEFORE: Manual header management
|
||||
// ============================================
|
||||
|
||||
// This is how services used to handle auth headers:
|
||||
/*
|
||||
import axios from 'axios'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
export async function oldWayToMakeRequest() {
|
||||
// Had to manually get auth headers
|
||||
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
|
||||
|
||||
if (!authHeaders) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Had to manually add headers to each request
|
||||
const response = await axios.get('/api/data', {
|
||||
headers: {
|
||||
...authHeaders,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AFTER: Automatic header injection
|
||||
// ============================================
|
||||
|
||||
// With the new system, auth headers are automatically injected:
|
||||
|
||||
/**
|
||||
* Example 1: Using fetchWithHeaders
|
||||
* Headers are automatically injected - no manual auth handling needed
|
||||
*/
|
||||
export async function modernFetchExample() {
|
||||
// Just make the request - auth headers are added automatically!
|
||||
const response = await fetchWithHeaders('/api/data', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
// Auth headers are automatically added by the AuthHeaderProvider
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 2: Using createAxiosWithHeaders
|
||||
* Create an axios client that automatically injects headers
|
||||
*/
|
||||
export function createModernApiClient() {
|
||||
// Create a client with automatic header injection
|
||||
const client = createAxiosWithHeaders({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
return {
|
||||
async getData() {
|
||||
// No need to manually add auth headers!
|
||||
const response = await client.get('/data')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async postData(data: any) {
|
||||
// Auth headers are automatically included
|
||||
const response = await client.post('/data', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateData(id: string, data: any) {
|
||||
// Works with all HTTP methods
|
||||
const response = await client.put(`/data/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 3: Real-world service refactoring
|
||||
* Shows how to update an existing service to use automatic headers
|
||||
*/
|
||||
|
||||
// Before: CustomerEventsService with manual auth
|
||||
/*
|
||||
class OldCustomerEventsService {
|
||||
private async makeRequest(url: string) {
|
||||
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (!authHeaders) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
return axios.get(url, { headers: authHeaders })
|
||||
}
|
||||
|
||||
async getEvents() {
|
||||
return this.makeRequest('/customers/events')
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// After: CustomerEventsService with automatic auth
|
||||
class ModernCustomerEventsService {
|
||||
private client = createAxiosWithHeaders({
|
||||
baseURL: '/api'
|
||||
})
|
||||
|
||||
async getEvents() {
|
||||
// Auth headers are automatically included!
|
||||
const response = await this.client.get('/customers/events')
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getEventDetails(eventId: string) {
|
||||
// No manual auth handling needed
|
||||
const response = await this.client.get(`/customers/events/${eventId}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Benefits of the new system:
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 1. Cleaner code - no auth header boilerplate
|
||||
* 2. Consistent auth handling across all services
|
||||
* 3. Automatic token refresh (handled by Firebase SDK)
|
||||
* 4. Fallback to API key when Firebase auth unavailable
|
||||
* 5. Easy to add new header providers (debug headers, etc.)
|
||||
* 6. Headers can be conditionally applied based on URL/method
|
||||
* 7. Priority system allows overriding headers when needed
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// How it works behind the scenes:
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 1. During app initialization (preInit hook), the AuthHeadersExtension
|
||||
* registers the AuthHeaderProvider with the header registry
|
||||
*
|
||||
* 2. When you use fetchWithHeaders or createAxiosWithHeaders, they
|
||||
* automatically query the header registry for all registered providers
|
||||
*
|
||||
* 3. The AuthHeaderProvider checks for Firebase token first, then
|
||||
* falls back to API key if needed
|
||||
*
|
||||
* 4. Headers are merged and added to the request automatically
|
||||
*
|
||||
* 5. If authentication fails, the request proceeds without auth headers
|
||||
* (the backend will handle the 401/403 response)
|
||||
*/
|
||||
|
||||
export const examples = {
|
||||
modernFetchExample,
|
||||
createModernApiClient,
|
||||
ModernCustomerEventsService
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Example of how extensions can register header providers
|
||||
* This file demonstrates the header registration API for extension developers
|
||||
*/
|
||||
import { headerRegistry } from '@/services/headerRegistry'
|
||||
import type {
|
||||
HeaderMap,
|
||||
HeaderProviderContext,
|
||||
IHeaderProvider
|
||||
} from '@/types/headerTypes'
|
||||
|
||||
/**
|
||||
* Example 1: Simple static header provider
|
||||
*/
|
||||
class StaticHeaderProvider implements IHeaderProvider {
|
||||
provideHeaders(_context: HeaderProviderContext): HeaderMap {
|
||||
return {
|
||||
'X-Extension-Name': 'my-extension',
|
||||
'X-Extension-Version': '1.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 2: Dynamic header provider that adds headers based on the request
|
||||
*/
|
||||
class DynamicHeaderProvider implements IHeaderProvider {
|
||||
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
|
||||
const headers: HeaderMap = {}
|
||||
|
||||
// Add different headers based on the URL
|
||||
if (context.url.includes('/api/')) {
|
||||
headers['X-API-Version'] = 'v2'
|
||||
}
|
||||
|
||||
// Add headers based on request method
|
||||
if (context.method === 'POST' || context.method === 'PUT') {
|
||||
headers['X-Request-ID'] = () => crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Add timestamp header
|
||||
headers['X-Timestamp'] = () => new Date().toISOString()
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 3: Auth token provider
|
||||
*/
|
||||
class AuthTokenProvider implements IHeaderProvider {
|
||||
private getToken(): string | null {
|
||||
// This could retrieve a token from storage, state, etc.
|
||||
return localStorage.getItem('auth-token')
|
||||
}
|
||||
|
||||
provideHeaders(_context: HeaderProviderContext): HeaderMap {
|
||||
const token = this.getToken()
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example of how to register providers in an extension
|
||||
*/
|
||||
export function setupHeaderProviders() {
|
||||
// Register a simple static provider
|
||||
const staticRegistration = headerRegistry.registerHeaderProvider(
|
||||
new StaticHeaderProvider()
|
||||
)
|
||||
|
||||
// Register a dynamic provider with higher priority
|
||||
const dynamicRegistration = headerRegistry.registerHeaderProvider(
|
||||
new DynamicHeaderProvider(),
|
||||
{ priority: 10 }
|
||||
)
|
||||
|
||||
// Register an auth provider that only applies to API endpoints
|
||||
const authRegistration = headerRegistry.registerHeaderProvider(
|
||||
new AuthTokenProvider(),
|
||||
{
|
||||
priority: 20, // Higher priority to override other auth headers
|
||||
filter: (context) => context.url.includes('/api/')
|
||||
}
|
||||
)
|
||||
|
||||
// Return cleanup function for when extension is unloaded
|
||||
return () => {
|
||||
staticRegistration.dispose()
|
||||
dynamicRegistration.dispose()
|
||||
authRegistration.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example of a provider that integrates with a cloud service
|
||||
*/
|
||||
export class CloudServiceHeaderProvider implements IHeaderProvider {
|
||||
constructor(
|
||||
private apiKey: string,
|
||||
private workspaceId: string
|
||||
) {}
|
||||
|
||||
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
|
||||
// Only add headers for requests to the cloud service
|
||||
if (!context.url.includes('cloud.comfyui.com')) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'X-API-Key': this.apiKey,
|
||||
'X-Workspace-ID': this.workspaceId,
|
||||
'X-Client-Version': '1.0.0',
|
||||
'X-Session-ID': async () => {
|
||||
// Could fetch or generate session ID asynchronously
|
||||
const sessionId = await this.getOrCreateSessionId()
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSessionId(): Promise<string> {
|
||||
// Simulate async session creation
|
||||
return 'session-' + Date.now()
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { headerRegistry } from '@/services/headerRegistry'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import type {
|
||||
HeaderMap,
|
||||
HeaderProviderContext,
|
||||
IHeaderProvider
|
||||
} from '@/types/headerTypes'
|
||||
|
||||
/**
|
||||
* Example extension showing how to register header providers
|
||||
* during the pre-init lifecycle hook.
|
||||
*
|
||||
* The pre-init hook is the earliest extension lifecycle hook,
|
||||
* called before the canvas is created. This makes it perfect
|
||||
* for registering cross-cutting concerns like header providers.
|
||||
*/
|
||||
|
||||
// Example: Authentication token provider
|
||||
class AuthTokenProvider implements IHeaderProvider {
|
||||
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
|
||||
// This could fetch tokens from a secure store, refresh them, etc.
|
||||
const token = await this.getAuthToken()
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
private async getAuthToken(): Promise<string | null> {
|
||||
// Example: Get token from localStorage or a secure store
|
||||
// In a real implementation, this might refresh tokens, handle expiration, etc.
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
}
|
||||
|
||||
// Example: API key provider for specific domains
|
||||
class ApiKeyProvider implements IHeaderProvider {
|
||||
private apiKeys: Record<string, string> = {
|
||||
'api.example.com': 'example-api-key',
|
||||
'api.another.com': 'another-api-key'
|
||||
}
|
||||
|
||||
provideHeaders(context: HeaderProviderContext): HeaderMap {
|
||||
const url = new URL(context.url)
|
||||
const apiKey = this.apiKeys[url.hostname]
|
||||
|
||||
if (apiKey) {
|
||||
return {
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Example: Custom header provider for debugging
|
||||
class DebugHeaderProvider implements IHeaderProvider {
|
||||
provideHeaders(_context: HeaderProviderContext): HeaderMap {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
'X-Debug-Mode': 'true',
|
||||
'X-Request-ID': crypto.randomUUID()
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const headerRegistrationExtension: ComfyExtension = {
|
||||
name: 'HeaderRegistration',
|
||||
|
||||
/**
|
||||
* Pre-init hook - called before canvas creation.
|
||||
* This is the perfect place to register header providers.
|
||||
*/
|
||||
async preInit(_app) {
|
||||
console.log(
|
||||
'[HeaderRegistration] Registering header providers in pre-init hook'
|
||||
)
|
||||
|
||||
// Register auth token provider with high priority
|
||||
const authRegistration = headerRegistry.registerHeaderProvider(
|
||||
new AuthTokenProvider(),
|
||||
{
|
||||
priority: 100
|
||||
}
|
||||
)
|
||||
|
||||
// Register API key provider
|
||||
const apiKeyRegistration = headerRegistry.registerHeaderProvider(
|
||||
new ApiKeyProvider(),
|
||||
{
|
||||
priority: 90
|
||||
}
|
||||
)
|
||||
|
||||
// Register debug header provider with lower priority
|
||||
const debugRegistration = headerRegistry.registerHeaderProvider(
|
||||
new DebugHeaderProvider(),
|
||||
{
|
||||
priority: 10
|
||||
}
|
||||
)
|
||||
|
||||
// Store registrations for potential cleanup later
|
||||
// Extensions can store their data on the app instance
|
||||
const extensionData = {
|
||||
headerRegistrations: [
|
||||
authRegistration,
|
||||
apiKeyRegistration,
|
||||
debugRegistration
|
||||
]
|
||||
}
|
||||
|
||||
// Store a reference on the extension itself for potential cleanup
|
||||
;(headerRegistrationExtension as any).registrations =
|
||||
extensionData.headerRegistrations
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard init hook - called after canvas creation.
|
||||
* At this point, header providers are already active.
|
||||
*/
|
||||
async init(_app) {
|
||||
console.log(
|
||||
'[HeaderRegistration] Headers are now being injected into all HTTP requests'
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Setup hook - called after app is fully initialized.
|
||||
* We could add UI elements here to manage headers.
|
||||
*/
|
||||
async setup(_app) {
|
||||
// Example: Add a command to test header injection
|
||||
const { useCommandStore } = await import('@/stores/commandStore')
|
||||
|
||||
useCommandStore().registerCommand({
|
||||
id: 'header-registration.test',
|
||||
icon: 'pi pi-globe',
|
||||
label: 'Test Header Injection',
|
||||
function: async () => {
|
||||
try {
|
||||
// Make a test request to see headers in action
|
||||
const response = await fetch('/api/test')
|
||||
console.log('[HeaderRegistration] Test request completed', {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[HeaderRegistration] Test request failed', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Extension usage:
|
||||
// 1. Import this extension in your extension index
|
||||
// 2. Register it with app.registerExtension(headerRegistrationExtension)
|
||||
// 3. Header providers will be automatically registered before any network activity
|
||||
@@ -1,29 +0,0 @@
|
||||
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
|
||||
import { app } from '@/scripts/app'
|
||||
import { headerRegistry } from '@/services/headerRegistry'
|
||||
|
||||
/**
|
||||
* Core extension that registers authentication header providers.
|
||||
* This ensures all HTTP requests automatically include authentication headers.
|
||||
*/
|
||||
app.registerExtension({
|
||||
name: 'Comfy.AuthHeaders',
|
||||
|
||||
/**
|
||||
* Register authentication header provider in the pre-init phase.
|
||||
* This ensures headers are available before any network activity.
|
||||
*/
|
||||
async preInit(_app) {
|
||||
console.log('[AuthHeaders] Registering authentication header provider')
|
||||
|
||||
// Register the auth header provider with high priority
|
||||
// This ensures auth headers are added to all requests
|
||||
headerRegistry.registerHeaderProvider(new AuthHeaderProvider(), {
|
||||
priority: 1000 // High priority to ensure auth headers are applied
|
||||
})
|
||||
|
||||
console.log(
|
||||
'[AuthHeaders] Authentication headers will be automatically injected'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
type ExecutableLGraphNode,
|
||||
type ExecutionId,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
@@ -1173,7 +1172,8 @@ export class GroupNodeHandler {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
getExtraMenuOptions?.apply(this, arguments)
|
||||
|
||||
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let optionIndex = options.findIndex((o) => o.content === 'Outputs')
|
||||
if (optionIndex === -1) optionIndex = options.length
|
||||
else optionIndex++
|
||||
options.splice(
|
||||
@@ -1634,57 +1634,6 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node (Deprecated)`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1780,9 +1729,6 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import './authHeaders'
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { fetchWithHeaders } from '@/services/networkClientAdapter'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
class Load3dUtils {
|
||||
@@ -10,7 +9,7 @@ class Load3dUtils {
|
||||
prefix: string,
|
||||
fileType: string = 'png'
|
||||
) {
|
||||
const blob = await fetchWithHeaders(imageData).then((r) => r.blob())
|
||||
const blob = await fetch(imageData).then((r) => r.blob())
|
||||
const name = `${prefix}_${Date.now()}.${fileType}`
|
||||
const file = new File([blob], name, {
|
||||
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'
|
||||
|
||||
@@ -4,8 +4,6 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
|
||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { fetchWithHeaders } from '@/services/networkClientAdapter'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
export class ModelExporter {
|
||||
@@ -38,18 +36,7 @@ export class ModelExporter {
|
||||
desiredFilename: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if this is a ComfyUI relative URL
|
||||
const isComfyUrl = url.startsWith('/') || url.includes('/view?')
|
||||
|
||||
let response: Response
|
||||
if (isComfyUrl) {
|
||||
// Use ComfyUI API client for internal URLs
|
||||
response = await fetchWithHeaders(api.apiURL(url))
|
||||
} else {
|
||||
// Use direct fetch for external URLs
|
||||
response = await fetchWithHeaders(url)
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
|
||||
const link = document.createElement('a')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { debounce } from 'lodash'
|
||||
import _ from 'lodash'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
|
||||
@@ -43,19 +43,6 @@ export class CanvasPointer {
|
||||
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
|
||||
static #maxClickDrift2 = this.#maxClickDrift ** 2
|
||||
|
||||
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
|
||||
static trackpadThreshold = 60
|
||||
|
||||
/**
|
||||
* The minimum time between "wheel" events to allow switching between trackpad
|
||||
* and mouse modes.
|
||||
*
|
||||
* This prevents trackpad "flick" panning from registering as regular mouse wheel.
|
||||
* After a flick gesture is complete, the automatic wheel events are sent with
|
||||
* reduced frequency, but much higher deltaX and deltaY values.
|
||||
*/
|
||||
static trackpadMaxGap = 200
|
||||
|
||||
/** The element this PointerState should capture input against when dragging. */
|
||||
element: Element
|
||||
/** Pointer ID used by drag capture. */
|
||||
@@ -90,9 +77,6 @@ export class CanvasPointer {
|
||||
/** The last pointerup event for the primary button */
|
||||
eUp?: CanvasPointerEvent
|
||||
|
||||
/** The last pointermove event that was treated as a trackpad gesture. */
|
||||
lastTrackpadEvent?: WheelEvent
|
||||
|
||||
/**
|
||||
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
|
||||
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
|
||||
@@ -273,35 +257,6 @@ export class CanvasPointer {
|
||||
delete this.onDragStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a continued trackpad gesture.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
|
||||
*/
|
||||
#isContinuationOfGesture(e: WheelEvent): boolean {
|
||||
const { lastTrackpadEvent } = this
|
||||
if (!lastTrackpadEvent) return false
|
||||
|
||||
return (
|
||||
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given wheel event is part of a trackpad gesture.
|
||||
* @param e The wheel event to check
|
||||
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
|
||||
*/
|
||||
isTrackpadGesture(e: WheelEvent): boolean {
|
||||
if (this.#isContinuationOfGesture(e)) {
|
||||
this.lastTrackpadEvent = e
|
||||
return true
|
||||
}
|
||||
|
||||
const threshold = CanvasPointer.trackpadThreshold
|
||||
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state of this {@link CanvasPointer} instance.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { Point, Rect } from './interfaces'
|
||||
import { LGraphCanvas } from './litegraph'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
import { toString } from 'lodash'
|
||||
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
@@ -20,7 +20,6 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
||||
import type {
|
||||
DefaultConnectionColors,
|
||||
Dictionary,
|
||||
HasBoundingRect,
|
||||
IContextMenuValue,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
@@ -29,8 +28,7 @@ import type {
|
||||
MethodNames,
|
||||
OptionalProps,
|
||||
Point,
|
||||
Positionable,
|
||||
Size
|
||||
Positionable
|
||||
} from './interfaces'
|
||||
import { LiteGraph, SubgraphNode } from './litegraph'
|
||||
import {
|
||||
@@ -1571,9 +1569,6 @@ export class LGraph
|
||||
boundingRect
|
||||
)
|
||||
|
||||
//Correct for title height. It's included in bounding box, but not _posSize
|
||||
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
|
||||
// Add the subgraph node to the graph
|
||||
this.add(subgraphNode)
|
||||
|
||||
@@ -1669,271 +1664,6 @@ export class LGraph
|
||||
return { subgraph, node: subgraphNode as SubgraphNode }
|
||||
}
|
||||
|
||||
unpackSubgraph(subgraphNode: SubgraphNode) {
|
||||
if (!(subgraphNode instanceof SubgraphNode))
|
||||
throw new Error('Can only unpack Subgraph Nodes')
|
||||
this.beforeChange()
|
||||
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
|
||||
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
|
||||
const positionables = [
|
||||
...subgraphNode.subgraph.nodes,
|
||||
...subgraphNode.subgraph.reroutes.values(),
|
||||
...subgraphNode.subgraph.groups
|
||||
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
|
||||
return {
|
||||
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
|
||||
}
|
||||
})
|
||||
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
|
||||
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
|
||||
|
||||
const toSelect: Positionable[] = []
|
||||
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
|
||||
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
|
||||
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
|
||||
const nodeIdMap = new Map<NodeId, NodeId>()
|
||||
for (const n_info of movedNodes) {
|
||||
const node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
nodeIdMap.set(n_info.id, ++this.last_node_id)
|
||||
node.id = this.last_node_id
|
||||
n_info.id = this.last_node_id
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.pos[0] += offsetX
|
||||
node.pos[1] += offsetY
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of node.outputs) {
|
||||
output.links = []
|
||||
}
|
||||
toSelect.push(node)
|
||||
}
|
||||
const groups = structuredClone(
|
||||
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
|
||||
)
|
||||
for (const g_info of groups) {
|
||||
const group = new LGraphGroup(g_info.title, g_info.id)
|
||||
this.add(group, true)
|
||||
group.configure(g_info)
|
||||
group.pos[0] += offsetX
|
||||
group.pos[1] += offsetY
|
||||
toSelect.push(group)
|
||||
}
|
||||
//cleanup reoute.linkIds now, but leave link.parentIds dangling
|
||||
for (const islot of subgraphNode.inputs) {
|
||||
if (!islot.link) continue
|
||||
const link = this.links.get(islot.link)
|
||||
if (!link) {
|
||||
console.warn('Broken link', islot, islot.link)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
for (const oslot of subgraphNode.outputs) {
|
||||
for (const linkId of oslot.links ?? []) {
|
||||
const link = this.links.get(linkId)
|
||||
if (!link) {
|
||||
console.warn('Broken link', oslot, linkId)
|
||||
continue
|
||||
}
|
||||
for (const reroute of LLink.getReroutes(this, link)) {
|
||||
reroute.linkIds.delete(link.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const newLinks: {
|
||||
oid: NodeId
|
||||
oslot: number
|
||||
tid: NodeId
|
||||
tslot: number
|
||||
id: LinkId
|
||||
iparent?: RerouteId
|
||||
eparent?: RerouteId
|
||||
externalFirst: boolean
|
||||
}[] = []
|
||||
for (const [, link] of subgraphNode.subgraph._links) {
|
||||
let externalParentId: RerouteId | undefined
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
|
||||
if (!outerLinkId) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
const outerLink = this.links[outerLinkId]
|
||||
link.origin_id = outerLink.origin_id
|
||||
link.origin_slot = outerLink.origin_slot
|
||||
externalParentId = outerLink.parentId
|
||||
} else {
|
||||
const origin_id = nodeIdMap.get(link.origin_id)
|
||||
if (!origin_id) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
link.origin_id = origin_id
|
||||
}
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
||||
[]) {
|
||||
const sublink = this.links[linkId]
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
tid: sublink.target_id,
|
||||
tslot: sublink.target_slot,
|
||||
id: link.id,
|
||||
iparent: link.parentId,
|
||||
eparent: sublink.parentId,
|
||||
externalFirst: true
|
||||
})
|
||||
sublink.parentId = undefined
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
const target_id = nodeIdMap.get(link.target_id)
|
||||
if (!target_id) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
}
|
||||
link.target_id = target_id
|
||||
}
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
tid: link.target_id,
|
||||
tslot: link.target_slot,
|
||||
id: link.id,
|
||||
iparent: link.parentId,
|
||||
eparent: externalParentId,
|
||||
externalFirst: false
|
||||
})
|
||||
}
|
||||
this.remove(subgraphNode)
|
||||
this.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
const linkIdMap = new Map<LinkId, LinkId[]>()
|
||||
for (const newLink of newLinks) {
|
||||
let created: LLink | null | undefined
|
||||
if (newLink.oid == SUBGRAPH_INPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
console.error('Ignoring link to subgraph outside subgraph')
|
||||
continue
|
||||
}
|
||||
const tnode = this._nodes_by_id[newLink.tid]
|
||||
created = this.inputNode.slots[newLink.oslot].connect(
|
||||
tnode.inputs[newLink.tslot],
|
||||
tnode
|
||||
)
|
||||
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
console.error('Ignoring link to subgraph outside subgraph')
|
||||
continue
|
||||
}
|
||||
const tnode = this._nodes_by_id[newLink.oid]
|
||||
created = this.outputNode.slots[newLink.tslot].connect(
|
||||
tnode.outputs[newLink.oslot],
|
||||
tnode
|
||||
)
|
||||
} else {
|
||||
created = this._nodes_by_id[newLink.oid].connect(
|
||||
newLink.oslot,
|
||||
this._nodes_by_id[newLink.tid],
|
||||
newLink.tslot
|
||||
)
|
||||
}
|
||||
if (!created) {
|
||||
console.error('Failed to create link')
|
||||
continue
|
||||
}
|
||||
//This is a little unwieldy since Map.has isn't a type guard
|
||||
const linkIds = linkIdMap.get(newLink.id) ?? []
|
||||
linkIds.push(created.id)
|
||||
if (!linkIdMap.has(newLink.id)) {
|
||||
linkIdMap.set(newLink.id, linkIds)
|
||||
}
|
||||
newLink.id = created.id
|
||||
}
|
||||
const rerouteIdMap = new Map<RerouteId, RerouteId>()
|
||||
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
|
||||
if (
|
||||
reroute.parentId !== undefined &&
|
||||
rerouteIdMap.get(reroute.parentId) === undefined
|
||||
) {
|
||||
console.error('Missing Parent ID')
|
||||
}
|
||||
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
|
||||
reroute.pos[0] + offsetX,
|
||||
reroute.pos[1] + offsetY
|
||||
])
|
||||
rerouteIdMap.set(reroute.id, migratedReroute.id)
|
||||
this.reroutes.set(migratedReroute.id, migratedReroute)
|
||||
toSelect.push(migratedReroute)
|
||||
}
|
||||
//iterate over newly created links to update reroute parentIds
|
||||
for (const newLink of newLinks) {
|
||||
const linkInstance = this.links.get(newLink.id)
|
||||
if (!linkInstance) {
|
||||
continue
|
||||
}
|
||||
let instance: Reroute | LLink | undefined = linkInstance
|
||||
let parentId: RerouteId | undefined = undefined
|
||||
if (newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
parentId = newLink.iparent
|
||||
while (parentId) {
|
||||
const migratedId = rerouteIdMap.get(parentId)
|
||||
if (!migratedId) throw new Error('Broken Id link when unpacking')
|
||||
instance.parentId = migratedId
|
||||
instance = this.reroutes.get(migratedId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
|
||||
if (!oldReroute) throw new Error('Broken Id link when unpacking')
|
||||
parentId = oldReroute.parentId
|
||||
}
|
||||
if (!newLink.externalFirst) {
|
||||
parentId = newLink.eparent
|
||||
while (parentId) {
|
||||
instance.parentId = parentId
|
||||
instance = this.reroutes.get(parentId)
|
||||
if (!instance) throw new Error('Broken Id link when unpacking')
|
||||
if (instance.linkIds.has(linkInstance.id))
|
||||
throw new Error('Infinite parentId loop')
|
||||
instance.linkIds.add(linkInstance.id)
|
||||
parentId = instance.parentId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of nodeIdMap.values()) {
|
||||
const node = this._nodes_by_id[nodeId]
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
this.canvasAction((c) => c.selectItems(toSelect))
|
||||
this.afterChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
|
||||
* Not intended to be run from subgraphs.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
import { toString } from 'lodash'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
|
||||
@@ -3456,6 +3456,10 @@ export class LGraphCanvas
|
||||
processMouseWheel(e: WheelEvent): void {
|
||||
if (!this.graph || !this.allow_dragcanvas) return
|
||||
|
||||
// TODO: Mouse wheel zoom rewrite
|
||||
// @ts-expect-error wheelDeltaY is non-standard property on WheelEvent
|
||||
const delta = e.wheelDeltaY ?? e.detail * -60
|
||||
|
||||
this.adjustMouseEvent(e)
|
||||
|
||||
const pos: Point = [e.clientX, e.clientY]
|
||||
@@ -3463,34 +3467,35 @@ export class LGraphCanvas
|
||||
|
||||
let { scale } = this.ds
|
||||
|
||||
// Detect if this is a trackpad gesture or mouse wheel
|
||||
const isTrackpad = this.pointer.isTrackpadGesture(e)
|
||||
|
||||
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
|
||||
// Legacy mode or standard mode with ctrl - use wheel for zoom
|
||||
if (isTrackpad) {
|
||||
// Trackpad gesture - use smooth scaling
|
||||
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
||||
} else {
|
||||
// Mouse wheel - use stepped scaling
|
||||
if (e.deltaY < 0) {
|
||||
scale *= this.zoom_speed
|
||||
} else if (e.deltaY > 0) {
|
||||
if (
|
||||
LiteGraph.canvasNavigationMode === 'legacy' ||
|
||||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
|
||||
) {
|
||||
if (delta > 0) {
|
||||
scale *= this.zoom_speed
|
||||
} else if (delta < 0) {
|
||||
scale *= 1 / this.zoom_speed
|
||||
}
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
} else if (
|
||||
LiteGraph.macTrackpadGestures &&
|
||||
(!LiteGraph.macGesturesRequireMac || navigator.userAgent.includes('Mac'))
|
||||
) {
|
||||
if (e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
if (e.deltaY > 0) {
|
||||
scale *= 1 / this.zoom_speed
|
||||
} else if (e.deltaY < 0) {
|
||||
scale *= this.zoom_speed
|
||||
}
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY])
|
||||
}
|
||||
} else {
|
||||
// Standard mode without ctrl - use wheel / gestures to pan
|
||||
// Trackpads and mice work on significantly different scales
|
||||
const factor = isTrackpad ? 0.18 : 0.008_333
|
||||
|
||||
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
|
||||
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
|
||||
} else if (e.ctrlKey) {
|
||||
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
|
||||
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
|
||||
} else if (e.shiftKey) {
|
||||
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
|
||||
} else {
|
||||
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
|
||||
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
|
||||
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
|
||||
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,7 +284,6 @@ export class LiteGraphGlobal {
|
||||
]
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
|
||||
* Tested on MacBook M4 Pro.
|
||||
* @default false
|
||||
@@ -293,7 +292,6 @@ export class LiteGraphGlobal {
|
||||
macTrackpadGestures: boolean = false
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
|
||||
* only be enabled when the browser user agent includes "Mac".
|
||||
* @default true
|
||||
|
||||
@@ -135,10 +135,6 @@ export class FloatingRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
|
||||
@@ -681,20 +681,6 @@ export class LinkConnector {
|
||||
let targetSlot = input
|
||||
|
||||
for (const link of renderLinks) {
|
||||
// Validate the connection type before proceeding
|
||||
if (
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
!link.canConnectToSubgraphInput(targetSlot)
|
||||
) {
|
||||
console.warn(
|
||||
'Invalid connection type',
|
||||
link.fromSlot.type,
|
||||
'->',
|
||||
targetSlot.type
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
link.connectToSubgraphInput(targetSlot, this.events)
|
||||
|
||||
// If we just connected to an EmptySubgraphInput, check if we should reuse the slot
|
||||
@@ -955,14 +941,6 @@ export class LinkConnector {
|
||||
)
|
||||
}
|
||||
|
||||
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
|
||||
return this.renderLinks.some(
|
||||
(link) =>
|
||||
'canConnectToSubgraphInput' in link &&
|
||||
link.canConnectToSubgraphInput(input)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a reroute is a valid drop target for any of the links being connected.
|
||||
* @param reroute The reroute that would be dropped on.
|
||||
|
||||
@@ -55,10 +55,6 @@ export class MovingOutputLink extends MovingLinkBase {
|
||||
return reroute.origin_id !== this.outputNode.id
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToInput(): never {
|
||||
throw new Error('MovingOutputLink cannot connect to an input.')
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@ export class ToOutputRenderLink implements RenderLink {
|
||||
return true
|
||||
}
|
||||
|
||||
canConnectToSubgraphInput(input: SubgraphInput): boolean {
|
||||
return input.isValidTarget(this.fromSlot)
|
||||
}
|
||||
|
||||
connectToOutput(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type {
|
||||
ReadOnlyRect,
|
||||
|
||||
@@ -171,12 +171,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
input._listenerController?.abort()
|
||||
input._listenerController = new AbortController()
|
||||
const { signal } = input._listenerController
|
||||
|
||||
@@ -212,12 +207,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
input._listenerController?.abort()
|
||||
}
|
||||
|
||||
this.inputs.length = 0
|
||||
@@ -266,14 +256,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
if (!subgraphInput) {
|
||||
// Skip inputs that don't exist in the subgraph definition
|
||||
// This can happen when loading workflows with dynamically added inputs
|
||||
console.warn(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping`
|
||||
if (!subgraphInput)
|
||||
throw new Error(
|
||||
`[SubgraphNode.configure] No subgraph input found for input ${input.name}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
this.#addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
@@ -532,12 +518,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
) {
|
||||
input._listenerController.abort()
|
||||
}
|
||||
input._listenerController?.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pull } from 'es-toolkit/compat'
|
||||
import { pull } from 'lodash'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { IKnobWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
|
||||
import type { ISliderWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import { MovingOutputLink } from '@/lib/litegraph/src/canvas/MovingOutputLink'
|
||||
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
|
||||
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
|
||||
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
|
||||
|
||||
describe('LinkConnector SubgraphInput connection validation', () => {
|
||||
let connector: LinkConnector
|
||||
const mockSetConnectingLinks = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
connector = new LinkConnector(mockSetConnectingLinks)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MovingOutputLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
|
||||
it('should validate type compatibility correctly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid link (number -> number)
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
|
||||
// Create invalid link (string -> number)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const numberInput = subgraph.inputs[0]
|
||||
|
||||
// Test validation
|
||||
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
|
||||
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle wildcard types', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'wildcard_input', type: '*' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
const wildcardInput = subgraph.inputs[0]
|
||||
|
||||
// Wildcard should accept any type
|
||||
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToOutputRenderLink validation', () => {
|
||||
it('should implement canConnectToSubgraphInput method', () => {
|
||||
// Create a minimal valid setup
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = new LGraphNode('TestNode')
|
||||
node.id = 1
|
||||
node.addInput('test_in', 'number')
|
||||
subgraph.add(node)
|
||||
|
||||
const slot = node.inputs[0] as NodeInputSlot
|
||||
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
|
||||
|
||||
// Verify the method exists
|
||||
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropOnIoNode validation', () => {
|
||||
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create an invalid link (string output -> string input, but subgraph expects number)
|
||||
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Mock console.warn to verify it's called
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it's NOT called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the invalid connection was skipped
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Invalid connection type',
|
||||
'string',
|
||||
'->',
|
||||
'number'
|
||||
)
|
||||
expect(connectSpy).not.toHaveBeenCalled()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should allow valid connections when dropping on SubgraphInputNode', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a valid link (number -> number)
|
||||
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
|
||||
subgraph._links.set(link.id, link)
|
||||
const movingLink = new MovingOutputLink(subgraph, link)
|
||||
|
||||
// Add the link to the connector
|
||||
connector.renderLinks.push(movingLink)
|
||||
connector.state.connectingTo = 'output'
|
||||
|
||||
// Create mock event
|
||||
const mockEvent = {
|
||||
canvasX: 100,
|
||||
canvasY: 100
|
||||
} as any
|
||||
|
||||
// Mock the getSlotInPosition to return the subgraph input
|
||||
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
|
||||
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
|
||||
|
||||
// Spy on connectToSubgraphInput to ensure it IS called
|
||||
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
|
||||
|
||||
// Drop on the SubgraphInputNode
|
||||
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
|
||||
|
||||
// Verify that the valid connection was made
|
||||
expect(connectSpy).toHaveBeenCalledWith(
|
||||
subgraph.inputs[0],
|
||||
connector.events
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSubgraphInputValidDrop', () => {
|
||||
it('should check if render links can connect to SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
sourceNode.addOutput('number_out', 'number')
|
||||
sourceNode.addOutput('string_out', 'string')
|
||||
subgraph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
targetNode.addInput('number_in', 'number')
|
||||
targetNode.addInput('string_in', 'string')
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create valid and invalid links
|
||||
const validLink = new LLink(
|
||||
1,
|
||||
'number',
|
||||
sourceNode.id,
|
||||
0,
|
||||
targetNode.id,
|
||||
0
|
||||
)
|
||||
const invalidLink = new LLink(
|
||||
2,
|
||||
'string',
|
||||
sourceNode.id,
|
||||
1,
|
||||
targetNode.id,
|
||||
1
|
||||
)
|
||||
subgraph._links.set(validLink.id, validLink)
|
||||
subgraph._links.set(invalidLink.id, invalidLink)
|
||||
|
||||
const validMovingLink = new MovingOutputLink(subgraph, validLink)
|
||||
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Test with only invalid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
|
||||
// Test with valid link
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
|
||||
// Test with mixed links
|
||||
connector.renderLinks.length = 0
|
||||
connector.renderLinks.push(invalidMovingLink, validMovingLink)
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle render links without canConnectToSubgraphInput method', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'number_input', type: 'number' }]
|
||||
})
|
||||
|
||||
// Create a mock render link without the method
|
||||
const mockLink = {
|
||||
fromSlot: { type: 'number' }
|
||||
// No canConnectToSubgraphInput method
|
||||
} as any
|
||||
|
||||
connector.renderLinks.push(mockLink)
|
||||
|
||||
const subgraphInput = subgraph.inputs[0]
|
||||
|
||||
// Should return false as the link doesn't have the method
|
||||
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { clamp } from 'lodash'
|
||||
import { beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LiteGraphGlobal } from '@/lib/litegraph/src/LiteGraphGlobal'
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ISlotType,
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './fixtures/subgraphHelpers'
|
||||
|
||||
function createNode(
|
||||
graph: LGraph,
|
||||
inputs: ISlotType[] = [],
|
||||
outputs: ISlotType[] = [],
|
||||
title?: string
|
||||
) {
|
||||
const type = JSON.stringify({ inputs, outputs })
|
||||
if (!LiteGraph.registered_node_types[type]) {
|
||||
class testnode extends LGraphNode {
|
||||
constructor(title: string) {
|
||||
super(title)
|
||||
let i_count = 0
|
||||
for (const input of inputs) this.addInput('input_' + i_count++, input)
|
||||
let o_count = 0
|
||||
for (const output of outputs)
|
||||
this.addOutput('output_' + o_count++, output)
|
||||
}
|
||||
}
|
||||
LiteGraph.registered_node_types[type] = testnode
|
||||
}
|
||||
const node = LiteGraph.createNode(type, title)
|
||||
if (!node) {
|
||||
throw new Error('Failed to create node')
|
||||
}
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
describe('SubgraphConversion', () => {
|
||||
describe('Subgraph Unpacking Functionality', () => {
|
||||
it('Should keep interior nodes and links', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const node1 = createNode(subgraph, [], ['number'])
|
||||
const node2 = createNode(subgraph, ['number'])
|
||||
node1.connect(0, node2, 0)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.links.size).toBe(1)
|
||||
})
|
||||
it('Should merge boundry links', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }],
|
||||
outputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const innerNode1 = createNode(subgraph, [], ['number'])
|
||||
const innerNode2 = createNode(subgraph, ['number'], [])
|
||||
subgraph.inputNode.slots[0].connect(innerNode2.inputs[0], innerNode2)
|
||||
subgraph.outputNode.slots[0].connect(innerNode1.outputs[0], innerNode1)
|
||||
|
||||
const outerNode1 = createNode(graph, [], ['number'])
|
||||
const outerNode2 = createNode(graph, ['number'])
|
||||
outerNode1.connect(0, subgraphNode, 0)
|
||||
subgraphNode.connect(0, outerNode2, 0)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.nodes.length).toBe(4)
|
||||
expect(graph.links.size).toBe(2)
|
||||
})
|
||||
it('Should keep reroutes and groups', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner = createNode(subgraph, [], ['number'])
|
||||
const innerLink = subgraph.outputNode.slots[0].connect(
|
||||
inner.outputs[0],
|
||||
inner
|
||||
)
|
||||
assert(innerLink)
|
||||
|
||||
const outer = createNode(graph, ['number'])
|
||||
const outerLink = subgraphNode.connect(0, outer, 0)
|
||||
assert(outerLink)
|
||||
subgraph.add(new LGraphGroup())
|
||||
|
||||
subgraph.createReroute([10, 10], innerLink)
|
||||
graph.createReroute([10, 10], outerLink)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(2)
|
||||
expect(graph.groups.length).toBe(1)
|
||||
})
|
||||
it('Should map reroutes onto split outputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [
|
||||
{ name: 'value1', type: 'number' },
|
||||
{ name: 'value2', type: 'number' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner = createNode(subgraph, [], ['number', 'number'])
|
||||
const innerLink1 = subgraph.outputNode.slots[0].connect(
|
||||
inner.outputs[0],
|
||||
inner
|
||||
)
|
||||
const innerLink2 = subgraph.outputNode.slots[1].connect(
|
||||
inner.outputs[1],
|
||||
inner
|
||||
)
|
||||
const outer1 = createNode(graph, ['number'])
|
||||
const outer2 = createNode(graph, ['number'])
|
||||
const outer3 = createNode(graph, ['number'])
|
||||
const outerLink1 = subgraphNode.connect(0, outer1, 0)
|
||||
assert(innerLink1 && innerLink2 && outerLink1)
|
||||
subgraphNode.connect(0, outer2, 0)
|
||||
subgraphNode.connect(1, outer3, 0)
|
||||
|
||||
subgraph.createReroute([10, 10], innerLink1)
|
||||
subgraph.createReroute([10, 20], innerLink2)
|
||||
graph.createReroute([10, 10], outerLink1)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
expect(graph.links.size).toBe(3)
|
||||
let linkRefCount = 0
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
linkRefCount += reroute.linkIds.size
|
||||
}
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
it('Should map reroutes onto split inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'value1', type: 'number' },
|
||||
{ name: 'value2', type: 'number' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const inner1 = createNode(subgraph, ['number', 'number'])
|
||||
const inner2 = createNode(subgraph, ['number'])
|
||||
const innerLink1 = subgraph.inputNode.slots[0].connect(
|
||||
inner1.inputs[0],
|
||||
inner1
|
||||
)
|
||||
const innerLink2 = subgraph.inputNode.slots[1].connect(
|
||||
inner1.inputs[1],
|
||||
inner1
|
||||
)
|
||||
const innerLink3 = subgraph.inputNode.slots[1].connect(
|
||||
inner2.inputs[0],
|
||||
inner2
|
||||
)
|
||||
assert(innerLink1 && innerLink2 && innerLink3)
|
||||
const outer = createNode(graph, [], ['number'])
|
||||
const outerLink1 = outer.connect(0, subgraphNode, 0)
|
||||
const outerLink2 = outer.connect(0, subgraphNode, 1)
|
||||
assert(outerLink1 && outerLink2)
|
||||
|
||||
graph.createReroute([10, 10], outerLink1)
|
||||
graph.createReroute([10, 20], outerLink2)
|
||||
subgraph.createReroute([10, 10], innerLink1)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.reroutes.size).toBe(3)
|
||||
expect(graph.links.size).toBe(3)
|
||||
let linkRefCount = 0
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
linkRefCount += reroute.linkIds.size
|
||||
}
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -131,9 +131,6 @@
|
||||
"Comfy_Graph_GroupSelectedNodes": {
|
||||
"label": "تجميع العقد المحددة"
|
||||
},
|
||||
"Comfy_Graph_UnpackSubgraph": {
|
||||
"label": "فك التفرع الفرعي المحدد"
|
||||
},
|
||||
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
|
||||
"label": "تحويل العقد المحددة إلى عقدة مجموعة"
|
||||
},
|
||||
@@ -185,9 +182,6 @@
|
||||
"Comfy_OpenClipspace": {
|
||||
"label": "Clipspace"
|
||||
},
|
||||
"Comfy_OpenManagerDialog": {
|
||||
"label": "مدير"
|
||||
},
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "فتح سير عمل"
|
||||
},
|
||||
@@ -215,12 +209,6 @@
|
||||
"Comfy_ShowSettingsDialog": {
|
||||
"label": "عرض نافذة الإعدادات"
|
||||
},
|
||||
"Comfy_ToggleCanvasInfo": {
|
||||
"label": "أداء اللوحة"
|
||||
},
|
||||
"Comfy_ToggleHelpCenter": {
|
||||
"label": "مركز المساعدة"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "تبديل النمط (فاتح/داكن)"
|
||||
},
|
||||
|
||||