Compare commits

..

4 Commits

Author SHA1 Message Date
bymyself
289817402c await toolbox commands added by extensions 2025-04-11 07:38:09 -07:00
bymyself
cb8ada3723 await queue prompt 2025-04-11 07:37:51 -07:00
bymyself
3d18bf75d9 await queue button actions 2025-04-11 07:32:14 -07:00
bymyself
ad0d1833d2 await command executions 2025-04-11 07:30:39 -07:00
294 changed files with 2488 additions and 13297 deletions

View File

@@ -8,15 +8,6 @@ const vue3CompositionApiBestPractices = [
"Use watch and watchEffect for side effects",
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
"Utilize provide/inject for dependency injection",
"Use vue 3.5 style of default prop declaration. Example:
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
",
"Organize vue component in <template> <script> <style> order",
]
// Folder structure
@@ -49,6 +40,4 @@ const additionalInstructions = `
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

View File

@@ -29,9 +29,9 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
USE_PROD_CONFIG: 'true'
run: |
npm ci
npm run fetch-templates
npm run build
npm run zipdist
- name: Upload dist artifact

View File

@@ -30,7 +30,7 @@ jobs:
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
ref: '49c8220be49120dbaff85f32813d854d6dff2d05'
- uses: actions/setup-node@v4
with:
@@ -39,6 +39,7 @@ jobs:
- name: Build ComfyUI_frontend
run: |
npm ci
npm run fetch-templates
npm run build
working-directory: ComfyUI_frontend

View File

@@ -4,13 +4,13 @@
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
modelName: 'gpt-4',
splitToken: 1024,
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
`

View File

@@ -1,25 +0,0 @@
{
"recommendations": [
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"vitest.explorer",
"vue.volar",
"sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,11 +0,0 @@
{
"8": {
"inputs": {
"image": "animated_web.webp"
},
"class_type": "DevToolsLoadAnimatedImageTest",
"_meta": {
"title": "Load Animated Image"
}
}
}

View File

@@ -1,60 +0,0 @@
{
"id": "3f1fcbf9-f9de-4935-8fad-401813f61b13",
"revision": 0,
"last_node_id": 10,
"last_link_id": 4,
"nodes": [
{
"id": 9,
"type": "SaveAnimatedWEBP",
"pos": [336, 104],
"size": [210, 368],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", 6, true, 80, "default"]
},
{
"id": 10,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [64, 104],
"size": [210, 316],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": ["animated_web.webp", "image"]
}
],
"links": [[4, 10, 0, 9, 0, "IMAGE"]],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0"
},
"version": 0.4
}

View File

@@ -463,128 +463,87 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDropExternalResource(
async dragAndDropFile(
fileName: string,
options: {
fileName?: string
url?: string
dropPosition?: Position
} = {}
) {
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
const { dropPosition = { x: 100, y: 100 } } = options
if (!fileName && !url)
throw new Error('Must provide either fileName or url')
const filePath = this.assetPath(fileName)
const evaluateParams: {
dropPosition: Position
fileName?: string
fileType?: string
buffer?: Uint8Array | number[]
url?: string
} = { dropPosition }
// Read the file content
const buffer = fs.readFileSync(filePath)
// Dropping a file from the filesystem
if (fileName) {
const filePath = this.assetPath(fileName)
const buffer = fs.readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
// Get file type
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
// Dropping a URL (e.g., dropping image across browser tabs in Firefox)
if (url) evaluateParams.url = url
const fileType = getFileType(fileName)
// Execute the drag and drop in the browser
await this.page.evaluate(async (params) => {
const dataTransfer = new DataTransfer()
// Add file if provided
if (params.buffer && params.fileName && params.fileType) {
const file = new File(
[new Uint8Array(params.buffer)],
params.fileName,
{
type: params.fileType
}
)
await this.page.evaluate(
async ({ buffer, fileName, fileType, dropPosition }) => {
const file = new File([new Uint8Array(buffer)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
}
// Add URL data if provided
if (params.url) {
dataTransfer.setData('text/uri-list', params.url)
dataTransfer.setData('text/x-moz-url', params.url)
}
const targetElement = document.elementFromPoint(
dropPosition.x,
dropPosition.y
)
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
console.error('No element found at drop position:', params.dropPosition)
return { success: false, error: 'No element at position' }
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
if (!targetElement) {
console.error('No element found at drop position:', dropPosition)
return { success: false, error: 'No element at position' }
}
}
}, evaluateParams)
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: dropPosition.x,
clientY: dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
}
},
{ buffer: [...new Uint8Array(buffer)], fileName, fileType, dropPosition }
)
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position } = {}
) {
return this.dragAndDropExternalResource({ fileName, ...options })
}
async dragAndDropURL(url: string, options: { dropPosition?: Position } = {}) {
return this.dragAndDropExternalResource({ url, ...options })
}
async dragNode2() {
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
await this.nextFrame()
@@ -926,7 +885,7 @@ export class ComfyPage {
async getNodeRefById(id: NodeId) {
return new NodeReference(id, this)
}
async getNodes(): Promise<LGraphNode[]> {
async getNodes() {
return await this.page.evaluate(() => {
return window['app'].graph.nodes
})

View File

@@ -341,30 +341,3 @@ test.describe('Error dialog', () => {
await expect(errorDialog).toBeVisible()
})
})
test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.getNodes()).length
await comfyPage.clickEmptyLatentNode()
await comfyPage.ctrlC()
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('test_password')
await textBox.press('Control+a')
await textBox.press('Control+c')
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.dialog.showSignInDialog()
})
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
await input.waitFor({ state: 'visible' })
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.getNodes()).toHaveLength(nodeNum)
})
})

View File

@@ -24,11 +24,4 @@ test.describe('DOM Widget', () => {
await expect(firstMultiline).not.toBeVisible()
await expect(lastMultiline).not.toBeVisible()
})
test('Position update when entering focus mode', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -3,35 +3,17 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Load Workflow in Media', () => {
const fileNames = [
;[
'workflow.webp',
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm',
'workflow.glb'
]
fileNames.forEach(async (fileName) => {
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})
const urls = [
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
]
urls.forEach(async (url) => {
test(`Load workflow from URL${url} (drop from different browser tabs)`, async ({
comfyPage
}) => {
await comfyPage.dragAndDropURL(url)
const readableName = url.split('/').pop()
await expect(comfyPage.canvas).toHaveScreenshot(
`dropped_workflow_url_${readableName}.png`
)
})
})
})

View File

@@ -92,20 +92,4 @@ test.describe('Node badge color', () => {
'node-badge-unknown-color-palette.png'
)
})
test('Can show node badge with light color palette', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.NodeBadge.NodeIdBadgeMode',
NodeBadgeMode.ShowAll
)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
// Click empty space to trigger canvas re-render.
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-badge-light-color-palette.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -1,17 +1,8 @@
import { Page, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import fs from 'fs'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function checkTemplateFileExists(
page: Page,
filename: string
): Promise<boolean> {
const response = await page.request.head(
new URL(`/templates/${filename}`, page.url()).toString()
)
return response.ok()
}
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
@@ -21,32 +12,32 @@ test.describe('Templates', () => {
test('should have a JSON workflow file for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const exists = await checkTemplateFileExists(
comfyPage.page,
const workflowPath = comfyPage.templates.getTemplatePath(
`${template.name}.json`
)
expect(exists, `Missing workflow: ${template.name}`).toBe(true)
expect(
fs.existsSync(workflowPath),
`Missing workflow: ${template.name}`
).toBe(true)
}
})
test('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()
const templates = await comfyPage.templates.getAllTemplates()
for (const template of templates) {
const { name, mediaSubtype, thumbnailVariant } = template
const baseMedia = `${name}-1.${mediaSubtype}`
const basePath = comfyPage.templates.getTemplatePath(baseMedia)
// Check base thumbnail
const baseExists = await checkTemplateFileExists(
comfyPage.page,
baseMedia
)
expect(baseExists, `Missing base thumbnail: ${baseMedia}`).toBe(true)
expect(
fs.existsSync(basePath),
`Missing base thumbnail: ${baseMedia}`
).toBe(true)
// Check second thumbnail for variants that need it
if (
@@ -54,12 +45,9 @@ test.describe('Templates', () => {
thumbnailVariant === 'hoverDissolve'
) {
const secondMedia = `${name}-2.${mediaSubtype}`
const secondExists = await checkTemplateFileExists(
comfyPage.page,
secondMedia
)
const secondPath = comfyPage.templates.getTemplatePath(secondMedia)
expect(
secondExists,
fs.existsSync(secondPath),
`Missing second thumbnail: ${secondMedia} required for ${thumbnailVariant}`
).toBe(true)
}
@@ -98,48 +86,4 @@ test.describe('Templates', () => {
// Expect the templates dialog to be shown
expect(await comfyPage.templates.content.isVisible()).toBe(true)
})
test('Uses title field as fallback when the key is not found in locales', async ({
comfyPage
}) => {
// Capture request for the index.json
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
// Add a new template that won't have a translation pre-generated
const response = [
{
moduleName: 'default',
title: 'FALLBACK CATEGORY',
type: 'image',
templates: [
{
name: 'unknown_key_has_no_translation_available',
title: 'FALLBACK TEMPLATE NAME',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'No translations found'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Load the templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
// Expect the title to be used as fallback for template cards
await expect(
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
).toBeVisible()
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
})

View File

@@ -186,105 +186,6 @@ test.describe('Image widget', () => {
})
})
test.describe('Animated image widget', () => {
test('Shows preview of uploaded animated image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped.png'
)
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the image preview to change to the next frame of the animation
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_drag_and_dropped_next_frame.png'
)
})
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_animated_webp')
// Get position of the load animated webp node
const nodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = nodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/save_animated_webp')
// Get position of the load animated webp node
const loadNodes = await comfyPage.getNodeRefsByType(
'DevToolsLoadAnimatedImageTest'
)
const loadAnimatedWebpNode = loadNodes[0]
const { x, y } = await loadAnimatedWebpNode.getPosition()
// Drag and drop image file onto the load animated webp node
await comfyPage.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
})
await comfyPage.nextFrame()
// Get the SaveAnimatedWEBP node
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
const saveAnimatedWebpNode = saveNodes[0]
if (!saveAnimatedWebpNode)
throw new Error('SaveAnimatedWEBP node not found')
// Simulate the graph executing
await comfyPage.page.evaluate(
([loadId, saveId]) => {
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
// Wait for animation to go to next frame
await comfyPage.page.waitForTimeout(512)
// Move mouse and click on canvas to trigger render
await comfyPage.page.mouse.click(64, 64)
// Expect the SaveAnimatedWEBP node to have an output preview
await expect(comfyPage.canvas).toHaveScreenshot(
'animated_image_preview_saved_webp.png'
)
})
})
test.describe('Load audio widget', () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_audio_widget')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -1,59 +0,0 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,82 +0,0 @@
import path from 'path'
import { Plugin } from 'vite'
interface ShimResult {
code: string
exports: string[]
}
function isLegacyFile(id: string): boolean {
return (
id.endsWith('.ts') &&
(id.includes('src/extensions/core') || id.includes('src/scripts'))
)
}
function transformExports(code: string, id: string): ShimResult {
const moduleName = getModuleName(id)
const exports: string[] = []
let newCode = code
// Regex to match different types of exports
const regex =
/export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g
let match
while ((match = regex.exec(code)) !== null) {
const name = match[2]
// All exports should be bind to the window object as new API endpoint.
if (exports.length == 0) {
newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`
newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`
}
newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`
exports.push(
`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`
)
}
return {
code: newCode,
exports
}
}
function getModuleName(id: string): string {
// Simple example to derive a module name from the file path
const parts = id.split('/')
const fileName = parts[parts.length - 1]
return fileName.replace(/\.\w+$/, '') // Remove file extension
}
export function comfyAPIPlugin(isDev: boolean): Plugin {
return {
name: 'comfy-api-plugin',
transform(code: string, id: string) {
if (isDev) return null
if (isLegacyFile(id)) {
const result = transformExports(code, id)
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const shimFileName = relativePath.replace(/\.ts$/, '.js')
const shimComment = `// Shim for ${relativePath}\n`
this.emitFile({
type: 'asset',
fileName: shimFileName,
source: shimComment + result.exports.join('')
})
}
return {
code: result.code,
map: null // If you're not modifying the source map, return null
}
}
}
}
}

View File

@@ -1,103 +0,0 @@
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
interface VendorLibrary {
name: string
pattern: RegExp
}
/**
* Vite plugin that generates an import map for vendor chunks.
*
* This plugin creates a browser-compatible import map that maps module specifiers
* (like 'vue' or 'primevue') to their actual file locations in the build output.
* This improves module loading in modern browsers and enables better caching.
*
* The plugin:
* 1. Tracks vendor chunks during bundle generation
* 2. Creates mappings between module names and their file paths
* 3. Injects an import map script tag into the HTML head
* 4. Configures manual chunk splitting for vendor libraries
*
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
vendorLibraries: VendorLibrary[]
): Plugin {
const importMapEntries: Record<string, string> = {}
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
config.build.rollupOptions.output = outputOptions
}
},
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
}
}
}
},
transformIndexHtml(html) {
if (Object.keys(importMapEntries).length === 0) {
console.warn(
'[ImportMap Plugin] No vendor chunks found to create import map.'
)
return html
}
const importMap = {
imports: importMapEntries
}
const importMapTag: HtmlTagDescriptor = {
tag: 'script',
attrs: { type: 'importmap' },
children: JSON.stringify(importMap, null, 2),
injectTo: 'head'
}
return {
html,
tags: [importMapTag]
}
}
}
}

View File

@@ -1,3 +0,0 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

View File

@@ -1,5 +1,4 @@
import pluginJs from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
@@ -21,26 +20,21 @@ export default [
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']
}
}
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
...pluginVue.configs['flat/essential'],
{
files: ['src/**/*.vue'],
languageOptions: {
parserOptions: {
parser: tseslint.parser
}
languageOptions: { parserOptions: { parser: tseslint.parser } }
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off'
}
},
{
@@ -48,12 +42,10 @@ export default [
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off'
'unused-imports/no-unused-imports': 'error'
}
}
]

1
global.d.ts vendored
View File

@@ -3,7 +3,6 @@ declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface Navigator {
/**

1289
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.17.10",
"version": "1.16.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -25,7 +25,8 @@
"lint:fix": "eslint src --fix",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts"
"json-schema": "tsx scripts/generate-json-schema.ts",
"fetch-templates": "tsx scripts/fetch-templates.ts"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
@@ -43,8 +44,6 @@
"autoprefixer": "^10.4.19",
"chalk": "^5.3.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
@@ -61,7 +60,7 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.18",
"vite": "^5.4.17",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
@@ -71,8 +70,8 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.39",
"@comfyorg/litegraph": "^0.13.8",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.13.1",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -90,7 +89,6 @@
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
@@ -103,7 +101,6 @@
"vue": "^3.5.13",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"vuefire": "^3.2.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,24 @@
import fs from 'fs-extra'
import { execSync } from 'node:child_process'
import path from 'node:path'
const workflowTemplatesRepo = 'https://github.com/Comfy-Org/workflow_templates'
const tempRepoDir = './templates_repo'
// Clone the repository
execSync(`git clone ${workflowTemplatesRepo} --depth 1 ${tempRepoDir}`)
// Create public/templates directory if it doesn't exist
fs.ensureDirSync('public/templates')
// Copy templates from repo to public/templates
const sourceDir = path.join(tempRepoDir, 'templates')
const targetDir = 'public/templates'
// Copy entire directory at once
fs.copySync(sourceDir, targetDir)
// Remove the temporary repository directory
fs.removeSync(tempRepoDir)
console.log('Templates fetched successfully')

View File

@@ -1,19 +1,19 @@
<template>
<Splitter
:key="activeSidebarTabId ?? undefined"
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:state-key="activeSidebarTabId ?? undefined"
state-storage="local"
:key="activeSidebarTabId ?? undefined"
:stateKey="activeSidebarTabId ?? undefined"
stateStorage="local"
>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
:size="20"
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'left'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<slot name="side-bar-panel" />
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel :size="100">
@@ -21,26 +21,26 @@
class="splitter-overlay max-w-full"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
state-key="bottom-panel-splitter"
state-storage="local"
stateKey="bottom-panel-splitter"
stateStorage="local"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel" />
<slot name="graph-canvas-panel"></slot>
</SplitterPanel>
<SplitterPanel v-show="bottomPanelVisible" class="bottom-panel">
<slot name="bottom-panel" />
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
<slot name="bottom-panel"></slot>
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
:size="20"
v-show="sidebarPanelVisible"
v-if="sidebarLocation === 'right'"
class="side-bar-panel"
:min-size="10"
:size="20"
>
<slot name="side-bar-panel" />
<slot name="side-bar-panel"></slot>
</SplitterPanel>
</Splitter>
</template>

View File

@@ -5,11 +5,11 @@
:style="positionCSS"
>
<Button
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
icon="pi pi-bars"
severity="secondary"
text
size="large"
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
:aria-label="$t('menu.showMenu')"
aria-live="assertive"
@click="exitFocusMode"

View File

@@ -1,19 +1,19 @@
<template>
<div
class="batch-count"
v-tooltip.bottom="{
value: $t('menu.batchCount'),
showDelay: 600
}"
class="batch-count"
:aria-label="$t('menu.batchCount')"
>
<InputNumber
v-model="batchCount"
class="w-14"
v-model="batchCount"
:min="minQueueCount"
:max="maxQueueCount"
fluid
show-buttons
showButtons
:pt="{
incrementButton: {
class: 'w-6',

View File

@@ -4,8 +4,9 @@
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div ref="panelRef" class="actionbar-content flex items-center select-none">
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2 p-0!" />
<div class="actionbar-content flex items-center select-none" ref="panelRef">
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
</span>
<ComfyQueueButton />
</div>
</Panel>
@@ -88,9 +89,9 @@ const setInitialPosition = () => {
}
}
onMounted(setInitialPosition)
watch(visible, async (newVisible) => {
watch(visible, (newVisible) => {
if (newVisible) {
await nextTick(setInitialPosition)
nextTick(setInitialPosition)
}
})

View File

@@ -1,19 +1,19 @@
<template>
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
severity="primary"
size="small"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="{
value: workspaceStore.shiftDown
? $t('menu.runWorkflowFront')
: $t('menu.runWorkflow'),
showDelay: 600
}"
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
severity="primary"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
>
<template #icon>
<i-lucide:list-start v-if="workspaceStore.shiftDown" />
@@ -23,15 +23,15 @@
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:label="String(item.label)"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
text
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
/>
</template>
</SplitButton>
@@ -47,8 +47,9 @@
:disabled="!executingPrompt"
text
:aria-label="$t('menu.interrupt')"
@click="() => commandStore.execute('Comfy.Interrupt')"
/>
@click="async () => await commandStore.execute('Comfy.Interrupt')"
>
</Button>
<Button
v-tooltip.bottom="{
value: $t('sideToolbar.queueTab.clearPendingTasks'),
@@ -60,9 +61,9 @@
text
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
@click="
() => {
async () => {
if (queueCountStore.count.value > 1) {
commandStore.execute('Comfy.ClearPendingTasks')
await commandStore.execute('Comfy.ClearPendingTasks')
}
queueMode = 'disabled'
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full">
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
<TabList pt:tab-list="border-none">
<TabList pt:tabList="border-none">
<div class="w-full flex justify-between">
<div class="tabs-container">
<Tab

View File

@@ -1,7 +1,7 @@
<template>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
<div class="relative overflow-hidden h-full w-full bg-black" ref="rootEl">
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
<div class="h-full terminal-host" ref="terminalEl"></div>
</div>
</div>
</template>

View File

@@ -24,17 +24,17 @@ const terminalCreated = (
root,
autoRows: true,
autoCols: true,
onResize: async () => {
onResize: () => {
// If we aren't visible, don't resize
if (!terminal.element?.offsetParent) return
await terminalApi.resize(terminal.cols, terminal.rows)
terminalApi.resize(terminal.cols, terminal.rows)
}
})
onMounted(async () => {
offData = terminal.onData(async (message: string) => {
await terminalApi.write(message)
terminalApi.write(message)
})
offOutput = terminalApi.onOutput((message) => {

View File

@@ -1,8 +1,6 @@
<template>
<div class="bg-black h-full w-full">
<p v-if="errorMessage" class="p-4 text-center">
{{ errorMessage }}
</p>
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
<ProgressSpinner
v-else-if="loading"
class="relative inset-0 flex justify-center items-center h-full z-10"
@@ -59,7 +57,7 @@ const terminalCreated = (
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
await api.subscribeLogs(true)
api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
@@ -78,9 +76,9 @@ const terminalCreated = (
loading.value = false
})
onUnmounted(async () => {
onUnmounted(() => {
if (api.clientId) {
await api.subscribeLogs(false)
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})

View File

@@ -1,75 +0,0 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex justify-between text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
<div>{{ t('apiNodesCostBreakdown.costPerRun') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="node in nodes"
:key="node.name"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
node.name
}}</span>
</div>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-base font-medium leading-tight">
{{ node.cost.toFixed(costPrecision) }}
</span>
</div>
</div>
</div>
</ScrollPanel>
<template v-if="showTotal && nodes.length > 1">
<Divider class="my-2" />
<div class="flex justify-between items-center border-t px-3">
<span class="text-sm">{{ t('apiNodesCostBreakdown.totalCost') }}</span>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-yellow-500 p-1"
/>
<span>{{ totalCost.toFixed(costPrecision) }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const {
nodes,
showTotal = true,
costPrecision = 3
} = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
costPrecision?: number
}>()
const totalCost = computed(() =>
nodes.reduce((sum, node) => sum + node.cost, 0)
)
</script>

View File

@@ -1,31 +0,0 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="nodeName in nodeNames"
:key="nodeName"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
nodeName
}}</span>
</div>
</div>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { nodeNames } = defineProps<{ nodeNames: string[] }>()
</script>

View File

@@ -5,8 +5,8 @@
<SelectButton
v-model="selectedColorOption"
:options="colorOptionsWithCustom"
option-label="name"
data-key="value"
optionLabel="name"
dataKey="value"
:allow-empty="false"
>
<template #option="slotProps">
@@ -18,8 +18,8 @@
backgroundColor: slotProps.option.value,
borderRadius: '50%'
}"
/>
<i v-else class="pi pi-palette text-lg" />
></div>
<i v-else class="pi pi-palette text-lg"></i>
</template>
</SelectButton>
<ColorPicker

View File

@@ -8,22 +8,22 @@
<img
v-if="contain"
:src="src"
@error="handleImageError"
:data-test="src"
class="comfy-image-blur"
:style="{ 'background-image': `url(${src})` }"
:alt="alt"
@error="handleImageError"
/>
<img
:src="src"
@error="handleImageError"
class="comfy-image-main"
:class="classProp"
:alt="alt"
@error="handleImageError"
/>
</span>
<div v-if="imageBroken" class="broken-image-placeholder">
<i class="pi pi-image" />
<i class="pi pi-image"></i>
<span>{{ $t('g.imageFailedToLoad') }}</span>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="container" />
<div ref="container"></div>
</template>
<script setup lang="ts">

View File

@@ -6,14 +6,14 @@
<SelectButton
v-model="selectedIcon"
:options="iconOptions"
option-label="name"
data-key="value"
optionLabel="name"
dataKey="value"
>
<template #option="slotProps">
<i
:class="['pi', slotProps.option.value, 'mr-2']"
:style="{ color: finalColor }"
/>
></i>
</template>
</SelectButton>
</div>
@@ -30,14 +30,14 @@
<Button
:label="$t('g.reset')"
icon="pi pi-refresh"
class="p-button-text"
@click="resetCustomization"
class="p-button-text"
/>
<Button
:label="$t('g.confirm')"
icon="pi pi-check"
autofocus
@click="confirmCustomization"
autofocus
/>
</template>
</Dialog>

View File

@@ -1,9 +1,7 @@
<template>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in deviceColumns" :key="col.field">
<div class="font-medium">
{{ col.header }}
</div>
<div class="font-medium">{{ col.header }}</div>
<div>
{{ formatValue(props.device[col.field], col.field) }}
</div>

View File

@@ -6,19 +6,19 @@
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<InputText
v-else
ref="inputRef"
v-model:modelValue="inputValue"
v-focus
type="text"
size="small"
fluid
v-model:modelValue="inputValue"
ref="inputRef"
@keyup.enter="blurInputElement"
@click.stop
:pt="{
root: {
onBlur: finishEditing
}
}"
@keyup.enter="blurInputElement"
@click.stop
v-focus
/>
</div>
</template>
@@ -45,10 +45,10 @@ const finishEditing = () => {
}
watch(
() => isEditing,
async (newVal) => {
(newVal) => {
if (newVal) {
inputValue.value = modelValue
await nextTick(() => {
nextTick(() => {
if (!inputRef.value) return
const fileName = inputValue.value.includes('.')
? inputValue.value.split('.').slice(0, -1).join('.')

View File

@@ -2,7 +2,7 @@
<template>
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2">
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<i class="pi pi-check text-green-500" v-if="status === 'completed'" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
@@ -14,20 +14,20 @@
<div class="file-action">
<Button
v-if="status === null || status === 'error'"
class="file-action-button"
:label="$t('g.download') + ' (' + fileSize + ')'"
size="small"
outlined
:disabled="!!props.error"
icon="pi pi-download"
@click="triggerDownload"
v-if="status === null || status === 'error'"
icon="pi pi-download"
/>
</div>
</div>
<div
v-if="status === 'in_progress' || status === 'paused'"
class="flex flex-row items-center gap-2"
v-if="status === 'in_progress' || status === 'paused'"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
@@ -39,36 +39,36 @@
/>
<Button
v-if="status === 'in_progress'"
v-tooltip.top="t('electronFileDownload.pause')"
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
icon="pi pi-pause-circle"
@click="triggerPauseDownload"
v-if="status === 'in_progress'"
icon="pi pi-pause-circle"
v-tooltip.top="t('electronFileDownload.pause')"
/>
<Button
v-if="status === 'paused'"
v-tooltip.top="t('electronFileDownload.resume')"
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
icon="pi pi-play-circle"
@click="triggerResumeDownload"
v-if="status === 'paused'"
icon="pi pi-play-circle"
v-tooltip.top="t('electronFileDownload.resume')"
/>
<Button
v-tooltip.top="t('electronFileDownload.cancel')"
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
@click="triggerCancelDownload"
icon="pi pi-times-circle"
severity="danger"
@click="triggerCancelDownload"
v-tooltip.top="t('electronFileDownload.cancel')"
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<component :is="extension.component" v-if="extension.type === 'vue'" />
<component v-if="extension.type === 'vue'" :is="extension.component" />
<div
v-else
:ref="
@@ -11,7 +11,7 @@
)
}
"
/>
></div>
</template>
<script setup lang="ts">

View File

@@ -10,7 +10,7 @@
:src="modelValue"
class="max-w-full max-h-full object-contain"
/>
<i v-else class="pi pi-image text-gray-400 text-xl" />
<i v-else class="pi pi-image text-gray-400 text-xl"></i>
</div>
<div class="flex flex-col gap-2">

View File

@@ -3,26 +3,26 @@
<div class="flex flex-row items-center gap-2">
<div class="form-label flex flex-grow items-center">
<span
:id="`${props.id}-label`"
class="text-muted"
:class="props.labelClass"
:id="`${props.id}-label`"
>
<slot name="name-prefix" />
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
v-if="props.item.tooltip"
v-tooltip="props.item.tooltip"
class="pi pi-info-circle bg-transparent"
v-tooltip="props.item.tooltip"
/>
<slot name="name-suffix" />
<slot name="name-suffix"></slot>
</span>
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:modelValue="formValue"
:aria-labelledby="`${props.id}-label`"
v-model:modelValue="formValue"
v-bind="getFormAttrs(props.item)"
/>
</div>

View File

@@ -1,26 +1,26 @@
<template>
<div class="input-knob flex flex-row items-center gap-2">
<Knob
:model-value="modelValue"
:value-template="displayValue"
:modelValue="modelValue"
@update:modelValue="updateValue"
:valueTemplate="displayValue"
class="knob-part"
:class="knobClass"
:min="min"
:max="max"
:step="step"
v-bind="$attrs"
@update:model-value="updateValue"
/>
<InputNumber
:model-value="modelValue"
:modelValue="modelValue"
@update:modelValue="updateValue"
class="input-part"
:max-fraction-digits="3"
:class="inputClass"
:min="min"
:max="max"
:step="step"
:allow-empty="false"
@update:model-value="updateValue"
:allowEmpty="false"
/>
</div>
</template>

View File

@@ -1,25 +1,25 @@
<template>
<div class="input-slider flex flex-row items-center gap-2">
<Slider
:model-value="modelValue"
:modelValue="modelValue"
@update:modelValue="(value) => updateValue(value as number)"
class="slider-part"
:class="sliderClass"
:min="min"
:max="max"
:step="step"
v-bind="$attrs"
@update:model-value="(value) => updateValue(value as number)"
/>
<InputNumber
:model-value="modelValue"
:modelValue="modelValue"
@update:modelValue="updateValue"
class="input-part"
:max-fraction-digits="3"
:class="inputClass"
:min="min"
:max="max"
:step="step"
:allow-empty="false"
@update:model-value="updateValue"
:allowEmpty="false"
/>
</div>
</template>

View File

@@ -3,16 +3,14 @@
<Card>
<template #content>
<div class="flex flex-col items-center">
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem"></i>
<h3>{{ title }}</h3>
<p class="whitespace-pre-line text-center">
{{ message }}
</p>
<p class="whitespace-pre-line text-center">{{ message }}</p>
<Button
v-if="buttonLabel"
:label="buttonLabel"
class="p-button-text"
@click="$emit('action')"
class="p-button-text"
/>
</div>
</template>

View File

@@ -22,7 +22,7 @@
class="p-button-icon pi pi-refresh transition-all"
:class="{ 'opacity-0': active }"
data-pc-section="icon"
/>
></span>
<span class="p-button-label" data-pc-section="label">&nbsp;</span>
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
</Button>

View File

@@ -11,9 +11,9 @@
/>
<InputText
class="search-box-input w-full"
:model-value="modelValue"
:placeholder="placeholder"
@input="handleInput"
:modelValue="modelValue"
:placeholder="placeholder"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
@@ -26,8 +26,8 @@
/>
</IconField>
<div
v-if="filters?.length"
class="search-filters pt-2 flex flex-wrap gap-2"
v-if="filters?.length"
>
<SearchFilterChip
v-for="filter in filters"

View File

@@ -1,14 +1,10 @@
<template>
<div class="system-stats">
<div class="mb-6">
<h2 class="text-2xl font-semibold mb-4">
{{ $t('g.systemInfo') }}
</h2>
<h2 class="text-2xl font-semibold mb-4">{{ $t('g.systemInfo') }}</h2>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div class="font-medium">
{{ col.header }}
</div>
<div class="font-medium">{{ col.header }}</div>
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
</template>
</div>
@@ -17,9 +13,7 @@
<Divider />
<div>
<h2 class="text-2xl font-semibold mb-4">
{{ $t('g.devices') }}
</h2>
<h2 class="text-2xl font-semibold mb-4">{{ $t('g.devices') }}</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"

View File

@@ -1,11 +1,11 @@
<template>
<Tree
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
class="tree-explorer py-0 px-2 2xl:px-4"
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
:value="renderedRoot.children"
selection-mode="single"
selectionMode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ context }) => ({
@@ -186,9 +186,9 @@ const menuItems = computed<MenuItem[]>(() =>
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
command: () => {
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
deleteCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,

View File

@@ -1,6 +1,5 @@
<template>
<div
ref="container"
:class="[
'tree-node',
{
@@ -9,16 +8,17 @@
'tree-leaf': props.node.leaf
}
]"
ref="container"
>
<div class="node-content">
<span class="node-label">
<slot name="before-label" :node="props.node" />
<slot name="before-label" :node="props.node"></slot>
<EditableText
:model-value="node.label"
:is-editing="isEditing"
:modelValue="node.label"
:isEditing="isEditing"
@edit="handleRename"
/>
<slot name="after-label" :node="props.node" />
<slot name="after-label" :node="props.node"></slot>
</span>
<Badge
v-if="showNodeBadgeText"
@@ -30,7 +30,7 @@
<div
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
<slot name="actions" :node="props.node"></slot>
</div>
</div>
</template>

View File

@@ -3,7 +3,7 @@
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<slot name="item" :item="item" />
<slot name="item" :item="item"> </slot>
</div>
</div>
<div

View File

@@ -60,7 +60,7 @@ describe('UrlInput', () => {
})
})
await wrapper.setProps({ modelValue: 'https://test.com' })
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
@@ -74,7 +74,7 @@ describe('UrlInput', () => {
validateUrlFn: () => Promise.resolve(true)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
@@ -88,7 +88,7 @@ describe('UrlInput', () => {
validateUrlFn: () => Promise.resolve(false)
})
await wrapper.setProps({ modelValue: 'https://test.com' })
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
@@ -141,14 +141,14 @@ describe('UrlInput', () => {
}
})
await wrapper.setProps({ modelValue: 'https://test.com' })
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
await wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
await nextTick()
await nextTick()

View File

@@ -13,13 +13,11 @@
>
<template #header>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:is="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
<h3 v-else :id="item.key">{{ item.title || ' ' }}</h3>
</template>
<component
@@ -28,7 +26,7 @@
:maximized="item.dialogComponentProps.maximized"
/>
<template v-if="item.footerComponent" #footer>
<template #footer v-if="item.footerComponent">
<component :is="item.footerComponent" />
</template>
</Dialog>

View File

@@ -1,44 +0,0 @@
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
<template>
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
<div class="text-2xl font-medium mb-2">
{{ t('apiNodesSignInDialog.title') }}
</div>
<div class="text-base mb-4">
{{ t('apiNodesSignInDialog.message') }}
</div>
<ApiNodesList :node-names="apiNodeNames" />
<div class="flex justify-between items-center">
<Button :label="t('g.learnMore')" link @click="handleLearnMoreClick" />
<div class="flex gap-2">
<Button
:label="t('g.cancel')"
outlined
severity="secondary"
@click="onCancel?.()"
/>
<Button :label="t('g.login')" @click="onLogin?.()" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { apiNodeNames, onLogin, onCancel } = defineProps<{
apiNodeNames: string[]
onLogin?: () => void
onCancel?: () => void
}>()
const handleLearnMoreClick = () => {
window.open('https://www.comfy.org/faq', '_blank')
}
</script>

View File

@@ -2,9 +2,7 @@
<section class="prompt-dialog-content flex flex-col gap-6 m-2 mt-4">
<span>{{ message }}</span>
<ul v-if="itemList?.length" class="pl-4 m-0 flex flex-col gap-2">
<li v-for="item of itemList" :key="item">
{{ item }}
</li>
<li v-for="item of itemList" :key="item">{{ item }}</li>
</ul>
<Message
v-if="hint"
@@ -20,53 +18,53 @@
:label="$t('g.cancel')"
icon="pi pi-undo"
severity="secondary"
autofocus
@click="onCancel"
autofocus
/>
<Button
v-if="type === 'default'"
:label="$t('g.confirm')"
severity="primary"
icon="pi pi-check"
@click="onConfirm"
icon="pi pi-check"
/>
<Button
v-else-if="type === 'delete'"
:label="$t('g.delete')"
severity="danger"
icon="pi pi-trash"
@click="onConfirm"
icon="pi pi-trash"
/>
<Button
v-else-if="type === 'overwrite'"
:label="$t('g.overwrite')"
severity="warn"
icon="pi pi-save"
@click="onConfirm"
icon="pi pi-save"
/>
<template v-else-if="type === 'dirtyClose'">
<Button
:label="$t('g.no')"
severity="secondary"
icon="pi pi-times"
@click="onDeny"
icon="pi pi-times"
/>
<Button :label="$t('g.save')" icon="pi pi-save" @click="onConfirm" />
<Button :label="$t('g.save')" @click="onConfirm" icon="pi pi-save" />
</template>
<Button
v-else-if="type === 'reinstall'"
:label="$t('desktopMenu.reinstall')"
severity="warn"
icon="pi pi-eraser"
@click="onConfirm"
icon="pi pi-eraser"
/>
<!-- Invalid - just show a close button. -->
<Button
v-else
:label="$t('g.close')"
severity="primary"
icon="pi pi-times"
@click="onCancel"
icon="pi pi-times"
/>
</div>
</section>

View File

@@ -25,13 +25,6 @@
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
<Button
v-if="authStore.currentUser"
v-show="!reportOpen"
text
:label="$t('issueReport.contactSupportTitle')"
@click="showContactSupport"
/>
</div>
<template v-if="reportOpen">
<Divider />
@@ -52,9 +45,9 @@
/>
<div class="flex gap-4 justify-end">
<FindIssueButton
:error-message="error.exceptionMessage"
:repo-owner="repoOwner"
:repo-name="repoName"
:errorMessage="error.exceptionMessage"
:repoOwner="repoOwner"
:repoName="repoName"
/>
<Button
v-if="reportOpen"
@@ -79,8 +72,6 @@ import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.v
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { ReportField } from '@/types/issueReportTypes'
import {
@@ -90,8 +81,6 @@ import {
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const authStore = useFirebaseAuthStore()
const { error } = defineProps<{
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
/**
@@ -134,10 +123,6 @@ const stackTraceField = computed<ReportField>(() => {
}
})
const showContactSupport = async () => {
await useCommandStore().execute('Comfy.ContactSupport')
}
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()

View File

@@ -8,9 +8,7 @@
>
<template #header>
<header class="flex flex-col items-center w-full">
<h2 id="issue-report-title" class="text-4xl">
{{ title }}
</h2>
<h2 id="issue-report-title" class="text-4xl">{{ title }}</h2>
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
</header>
</template>

View File

@@ -7,8 +7,8 @@
/>
<ListBox
:options="uniqueNodes"
option-label="label"
scroll-height="100%"
optionLabel="label"
scrollHeight="100%"
class="comfy-missing-nodes"
:pt="{
list: { class: 'border-none' }
@@ -22,16 +22,16 @@
}}</span>
<Button
v-if="slotProps.option.action"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
size="small"
outlined
@click="slotProps.option.action.callback"
/>
</div>
</template>
</ListBox>
<div class="flex justify-end py-3">
<Button label="Open Manager" size="small" outlined @click="openManager" />
<Button label="Open Manager" @click="openManager" size="small" outlined />
</div>
</template>

View File

@@ -129,12 +129,9 @@ const missingModels = computed(() => {
})
})
onBeforeUnmount(async () => {
onBeforeUnmount(() => {
if (doNotAskAgain.value) {
await useSettingStore().set(
'Comfy.Workflow.ShowMissingModelsWarning',
false
)
useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', false)
}
})
</script>

View File

@@ -4,15 +4,13 @@
<InputText
ref="inputRef"
v-model="inputValue"
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
autofocus
/>
<label>{{ message }}</label>
</FloatLabel>
<Button @click="onConfirm">
{{ $t('g.confirm') }}
</Button>
<Button @click="onConfirm">{{ $t('g.confirm') }}</Button>
</div>
</template>

View File

@@ -2,36 +2,30 @@
<div class="settings-container">
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-48 2xl:w-64">
<SearchBox
v-model:modelValue="searchQuery"
class="settings-search-box w-full mb-2"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
@search="handleSearch"
/>
<Listbox
v-model="activeCategory"
:options="groupedMenuTreeNodes"
option-label="translatedLabel"
option-group-label="label"
option-group-children="children"
scroll-height="100%"
:option-disabled="
:options="categories"
optionLabel="translatedLabel"
scrollHeight="100%"
:optionDisabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="border-none w-full"
>
<template #optiongroup>
<Divider class="my-0" />
</template>
</Listbox>
/>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4 hidden md:flex" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
<SettingsPanel :settingGroups="searchResults" />
</PanelTemplate>
<PanelTemplate
@@ -44,12 +38,10 @@
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
</template>
<SettingsPanel :setting-groups="sortedGroups(category)" />
<SettingsPanel :settingGroups="sortedGroups(category)" />
</PanelTemplate>
<AboutPanel />
<UserPanel />
<CreditsPanel />
<Suspense>
<KeybindingPanel />
<template #fallback>
@@ -81,33 +73,30 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, defineAsyncComponent, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { useSettingUI } from '@/composables/setting/useSettingUI'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { SettingTreeNode } from '@/stores/settingStore'
import { st } from '@/i18n'
import {
SettingTreeNode,
getSettingInfo,
useSettingStore
} from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import ColorPaletteMessage from './setting/ColorPaletteMessage.vue'
import CreditsPanel from './setting/CreditsPanel.vue'
import CurrentUserMessage from './setting/CurrentUserMessage.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import SettingsPanel from './setting/SettingsPanel.vue'
import UserPanel from './setting/UserPanel.vue'
const { defaultPanel } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
const props = defineProps<{
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
}>()
const KeybindingPanel = defineAsyncComponent(
@@ -120,25 +109,71 @@ const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
const {
activeCategory,
defaultCategory,
settingCategories,
groupedMenuTreeNodes
} = useSettingUI(defaultPanel)
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
children: []
}
const {
searchQuery,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
children: []
}
const authStore = useFirebaseAuthStore()
const extensionPanelNode: SettingTreeNode = {
key: 'extension',
label: 'Extension',
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
/**
* Server config panel is only available in Electron. We might want to support
* it in the web version in the future.
*/
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
return isElectron() ? [serverConfigPanelNode] : []
})
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const { t } = useI18n()
const categories = computed<SettingTreeNode[]>(() =>
[
...settingCategories.value,
keybindingPanelNode,
extensionPanelNode,
...serverConfigPanelNodeList.value,
aboutPanelNode
].map((node) => ({
...node,
translatedLabel: t(
`settingsCategories.${normalizeI18nKey(node.label)}`,
node.label
)
}))
)
const activeCategory = ref<SettingTreeNode | null>(null)
const getDefaultCategory = () => {
return props.defaultPanel
? categories.value.find((x) => x.key === props.defaultPanel) ??
categories.value[0]
: categories.value[0]
}
onMounted(() => {
activeCategory.value = getDefaultCategory()
})
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
@@ -148,29 +183,98 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
}))
}
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
const searchResults = computed<ISettingGroup[]>(() => {
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupLabel = info.subCategory
if (
activeCategory.value === null ||
activeCategory.value.label === info.category
) {
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
})
/**
* Settings categories that contains at least one setting in search results.
*/
const searchResultsCategories = computed<Set<string>>(() => {
return new Set(
filteredSettingIds.value.map(
(id) => getSettingInfo(settingStore.settingsById[id]).category
)
)
})
const handleSearch = (query: string) => {
handleSearchBase(query)
activeCategory.value = query ? null : defaultCategory.value
if (!query) {
filteredSettingIds.value = []
activeCategory.value ??= getDefaultCategory()
return
}
const queryLower = query.toLocaleLowerCase()
const allSettings = flattenTree<SettingParams>(settingRoot.value)
const filteredSettings = allSettings.filter((setting) => {
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(
`settings.${normalizeI18nKey(setting.id)}.name`,
setting.name
).toLocaleLowerCase()
const info = getSettingInfo(setting)
const translatedCategory = st(
`settingsCategories.${normalizeI18nKey(info.category)}`,
info.category
).toLocaleLowerCase()
const translatedSubCategory = st(
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
info.subCategory
).toLocaleLowerCase()
return (
idLower.includes(queryLower) ||
nameLower.includes(queryLower) ||
translatedName.includes(queryLower) ||
translatedCategory.includes(queryLower) ||
translatedSubCategory.includes(queryLower)
)
})
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
activeCategory.value = null
}
// Get search results
const searchResults = computed<ISettingGroup[]>(() =>
getSearchResults(activeCategory.value)
)
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
if (!tabValue.value) {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authStore.fetchBalance()
}
})
</script>
@@ -189,10 +293,6 @@ watch(activeCategory, (_, oldValue) => {
overflow: hidden;
}
.settings-content {
overflow-x: auto;
}
@media (max-width: 768px) {
.settings-container {
flex-direction: column;
@@ -209,8 +309,14 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Hide the first group separator */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
/* Show a separator line above the Keybinding tab */
/* This indicates the start of custom setting panels */
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) {
position: relative;
}
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before {
@apply content-[''] top-0 left-0 absolute w-full;
border-top: 1px solid var(--p-divider-border-color);
}
</style>

View File

@@ -1,132 +0,0 @@
<template>
<div class="w-96 p-2">
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span>
</p>
</div>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<SignUpForm v-else @submit="signInWithEmail" />
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</div>
<!-- Terms -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }}
</a>
.
</p>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
const { t } = useI18n()
const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const firebaseAuthStore = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isSignIn = ref(true)
const toggleState = () => {
isSignIn.value = !isSignIn.value
}
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGoogle()
onSuccess()
})
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
await firebaseAuthStore.loginWithGithub()
onSuccess()
})
const signInWithEmail = wrapWithErrorHandlingAsync(
async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
}
onSuccess()
}
)
</script>

View File

@@ -1,158 +0,0 @@
<template>
<div class="flex flex-col p-6">
<div
class="flex items-center gap-2"
:class="{ 'text-red-500': isInsufficientCredits }"
>
<i
:class="[
'text-2xl',
isInsufficientCredits ? 'pi pi-exclamation-triangle' : ''
]"
/>
<h2 class="text-2xl font-semibold">
{{
$t(
isInsufficientCredits
? 'credits.topUp.insufficientTitle'
: 'credits.topUp.title'
)
}}
</h2>
</div>
<!-- Error Message -->
<p v-if="isInsufficientCredits" class="text-lg text-muted mt-6">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
<!-- Balance Section -->
<div class="flex justify-between items-center mt-8">
<div class="flex flex-col gap-2">
<div class="text-muted">{{ $t('credits.yourCreditBalance') }}</div>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-2xl">{{ formattedBalance }}</span>
</div>
</div>
<Button
text
severity="secondary"
:label="$t('credits.creditsHistory')"
icon="pi pi-arrow-up-right"
@click="handleSeeDetails"
/>
</div>
<!-- Amount Input Section -->
<div class="flex flex-col gap-2 mt-8">
<div>
<span class="text-muted">{{ $t('credits.topUp.addCredits') }}</span>
<span class="text-muted text-sm ml-1">{{
$t('credits.topUp.maxAmount')
}}</span>
</div>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<InputNumber
v-model="amount"
:min="1"
:max="1000"
:step="1"
mode="currency"
currency="USD"
show-buttons
@blur="handleBlur"
@input="handleInput"
/>
</div>
</div>
<div class="flex justify-end mt-8">
<ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button
v-else
severity="primary"
:label="$t('credits.topUp.buyNow')"
:disabled="!amount || amount > 1000"
:pt="{
root: { class: 'px-8' }
}"
@click="handleBuyNow"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency, usdToMicros } from '@/utils/formatUtil'
defineProps<{
isInsufficientCredits?: boolean
}>()
const authStore = useFirebaseAuthStore()
const amount = ref<number>(9.99)
const didClickBuyNow = ref(false)
const loading = computed(() => authStore.loading)
const handleBlur = (e: any) => {
if (e.target.value) {
amount.value = parseFloat(e.target.value)
}
}
const handleInput = (e: any) => {
amount.value = e.value
}
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.000'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const handleSeeDetails = async () => {
const response = await authStore.accessBillingPortal()
if (!response?.billing_portal_url) return
window.open(response.billing_portal_url, '_blank')
}
const handleBuyNow = async () => {
if (!amount.value) return
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount.value),
currency: 'usd'
})
if (!response?.checkout_url) return
didClickBuyNow.value = true
// Go to Stripe checkout page
window.open(response.checkout_url, '_blank')
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authStore.fetchBalance()
}
})
</script>

View File

@@ -1,10 +1,11 @@
<template>
<Button
@click="openGitHubIssues"
:label="$t('g.findIssues')"
severity="secondary"
icon="pi pi-github"
@click="openGitHubIssues"
/>
>
</Button>
</template>
<script setup lang="ts">

View File

@@ -1,8 +1,8 @@
<template>
<Form
v-slot="$form"
:resolver="zodResolver(issueReportSchema)"
@submit="submit"
:resolver="zodResolver(issueReportSchema)"
>
<Panel :pt="$attrs.pt as any">
<template #header>
@@ -23,136 +23,75 @@
</div>
</template>
<div class="p-4 mt-2 border border-round surface-border shadow-1">
<div class="flex flex-col gap-6">
<FormField
v-slot="$field"
name="contactInfo"
:initial-value="authStore.currentUser?.email"
>
<div class="self-stretch inline-flex justify-start items-center">
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.email')
}}</label>
</div>
<InputText
id="contactInfo"
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
<div class="flex flex-row gap-3 mb-2">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
>
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<FormField v-slot="$field" name="helpType">
<div class="flex flex-col gap-2">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.whatDoYouNeedHelpWith')
}}</label>
</div>
<Dropdown
<Checkbox
v-bind="$field"
v-model="$field.value"
:options="helpTypes"
option-label="label"
option-value="value"
:placeholder="$t('issueReport.selectIssue')"
class="w-full"
:inputId="field.value"
:value="field.value"
v-model="selection"
/>
<Message
v-if="$field?.error"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.selectIssueType') }}
</Message>
</div>
</FormField>
<div class="flex flex-col gap-2">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<span class="pb-2 pt-0 opacity-80">{{
$t('issueReport.whatCanWeInclude')
}}</span>
</div>
<div class="flex flex-row gap-3">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
v-model="selection"
:input-id="field.value"
:value="field.value"
/>
<label :for="field.value">{{ field.label }}</label>
</FormField>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<FormField v-slot="$field" name="details">
<div
class="self-stretch inline-flex justify-start items-center gap-2.5"
>
<label for="details" class="pb-2 pt-0 opacity-80">{{
$t('issueReport.describeTheProblem')
}}</label>
</div>
<Textarea
v-bind="$field"
id="details"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
<label :for="field.value">{{ field.label }}</label>
</FormField>
</div>
</div>
<FormField class="mb-4" v-slot="$field" name="details">
<Textarea
v-bind="$field"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
</FormField>
<FormField v-slot="$field" name="contactInfo">
<InputText
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<div class="flex flex-col gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
v-model="contactPrefs"
:input-id="checkbox.value"
:value="checkbox.value"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
</div>
<div class="flex flex-row gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
:inputId="checkbox.value"
:value="checkbox.value"
v-model="contactPrefs"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
</div>
</div>
</div>
@@ -162,6 +101,7 @@
<script setup lang="ts">
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
@@ -169,7 +109,6 @@ import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Panel from 'primevue/panel'
@@ -184,16 +123,14 @@ import {
} from '@/schemas/issueReportSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
DefaultField,
IssueReportPanelProps,
ReportField
} from '@/types/issueReportTypes'
import { isElectron } from '@/utils/envUtil'
import { generateUUID } from '@/utils/formatUtil'
const DEFAULT_ISSUE_NAME = 'User reported issue'
const ISSUE_NAME = 'User reported issue'
const props = defineProps<IssueReportPanelProps>()
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
@@ -201,7 +138,6 @@ const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
const { t } = useI18n()
const toast = useToast()
const authStore = useFirebaseAuthStore()
const selection = ref<string[]>([])
const contactPrefs = ref<string[]>([])
@@ -212,20 +148,6 @@ const contactCheckboxes = [
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
]
const helpTypes = [
{
label: t('issueReport.helpTypes.billingPayments'),
value: 'billingPayments'
},
{
label: t('issueReport.helpTypes.loginAccessIssues'),
value: 'loginAccessIssues'
},
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
]
const defaultFieldsConfig: ReportField[] = [
{
label: t('issueReport.systemStats'),
@@ -292,7 +214,6 @@ const createCaptureContext = async (
level: 'error',
tags: {
errorType: props.errorType,
helpType: formData.helpType,
followUp: formData.contactInfo ? formData.followUp : false,
notifyOnResolution: formData.contactInfo
? formData.notifyOnResolution
@@ -307,24 +228,11 @@ const createCaptureContext = async (
}
}
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
const submit = async (event: FormSubmitEvent) => {
if (event.valid) {
try {
const captureContext = await createCaptureContext(event.values)
// If it's billing or access issue, generate unique id to be used by customer service ticketing
const isValidContactInfo = event.values.contactInfo?.length
const isCustomerServiceIssue =
isValidContactInfo &&
['billingPayments', 'loginAccessIssues'].includes(
event.values.helpType || ''
)
const issueName = isCustomerServiceIssue
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
: DEFAULT_ISSUE_NAME
captureMessage(issueName, captureContext)
captureMessage(ISSUE_NAME, captureContext)
submitted.value = true
toast.add({
severity: 'success',

View File

@@ -1,6 +1,5 @@
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Checkbox from 'primevue/checkbox'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -66,12 +65,7 @@ vi.mock('@/scripts/api', () => ({
api: {
getLogs: vi.fn().mockResolvedValue('mock logs'),
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
getSettings: vi.fn().mockResolvedValue('mock settings'),
fetchApi: vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue('')
}),
apiURL: vi.fn().mockReturnValue('https://test.com')
getSettings: vi.fn().mockResolvedValue('mock settings')
}
}))
@@ -145,14 +139,12 @@ vi.mock('@primevue/forms', () => ({
describe('ReportIssuePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
const pinia = createPinia()
setActivePinia(pinia)
})
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
return mount(ReportIssuePanel, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
},
props,

View File

@@ -14,8 +14,8 @@
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selectedTab="selectedTab"
:tabs="tabs"
v-model:selectedTab="selectedTab"
/>
<div
class="flex-1 overflow-auto pr-80"
@@ -29,8 +29,7 @@
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:searchResults="searchResults"
:suggestions="suggestions"
/>
<div class="flex-1 overflow-auto">
@@ -57,16 +56,16 @@
<VirtualGrid
:items="resultsWithKeys"
:buffer-rows="3"
:grid-style="GRID_STYLE"
:gridStyle="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
@click.stop="(event) => selectNodePack(item, event)"
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event) => selectNodePack(item, event)"
/>
</template>
</VirtualGrid>
@@ -74,7 +73,9 @@
</div>
</div>
</div>
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<div
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
@@ -165,7 +166,6 @@ const {
isLoading: isSearchLoading,
searchResults,
searchMode,
sortField,
suggestions
} = useRegistrySearch()
pageNumber.value = 0
@@ -222,13 +222,13 @@ const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
watch(
[isUpdateAvailableTab, installedPacks],
async () => {
() => {
if (!isUpdateAvailableTab.value) return
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (!installedPacks.value.length) {
await startFetchInstalled()
startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
}
@@ -238,7 +238,7 @@ watch(
watch(
[isInstalledTab, installedPacks],
async () => {
() => {
if (!isInstalledTab.value) return
if (!isEmptySearch.value) {
@@ -248,7 +248,7 @@ watch(
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
await startFetchInstalled()
startFetchInstalled()
} else {
displayPacks.value = installedPacks.value
}
@@ -258,7 +258,7 @@ watch(
watch(
[isMissingTab, isWorkflowTab, workflowPacks, installedPacks],
async () => {
() => {
if (!isWorkflowTab.value && !isMissingTab.value) return
if (!isEmptySearch.value) {
@@ -270,9 +270,9 @@ watch(
!isLoadingWorkflow.value &&
!workflowPacksReady.value
) {
await startFetchWorkflowPacks()
startFetchWorkflowPacks()
if (isMissingTab.value) {
await startFetchInstalled()
startFetchInstalled()
}
} else {
displayPacks.value = isMissingTab.value

View File

@@ -6,8 +6,8 @@
<Listbox
v-model="selectedTab"
:options="tabs"
option-label="label"
list-style="max-height:unset"
optionLabel="label"
listStyle="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-5' },
@@ -17,7 +17,7 @@
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>

View File

@@ -10,7 +10,7 @@
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
/>
></i>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>

View File

@@ -10,7 +10,7 @@
<ProgressSpinner class="w-8 h-8 mb-2" />
{{ $t('manager.loadingVersions') }}
</div>
<div v-else-if="versionOptions.length === 0" class="py-2">
<div v-else-if="allVersionOptions.length === 0" class="py-2">
<NoResultsPlaceholder
:title="$t('g.noResultsFound')"
:message="$t('manager.tryAgainLater')"
@@ -23,7 +23,7 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="versionOptions"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
>
@@ -58,11 +58,11 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { useAsyncState, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -119,53 +119,47 @@ const fetchVersions = async () => {
return (await registryService.getPackVersions(nodePack.id)) || []
}
const versionOptions = ref<
{
value: string
label: string
}[]
>([])
const {
isLoading: isLoadingVersions,
state: versions,
execute: startFetchVersions
} = useAsyncState(fetchVersions, [])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
isLoadingVersions.value = true
// Fetch versions from the registry
const versions = await fetchVersions()
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
// Add Latest option
const defaultVersions = [
const specialOptions = computed(() => {
const options = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
]
// Add Nightly option if there is a non-empty `repository` field
// Only include nightly option if there is a repo
if (nodePack.repository?.length) {
defaultVersions.push({
options.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
versionOptions.value = [...defaultVersions, ...availableVersionOptions]
isLoadingVersions.value = false
}
return options
})
const versionOptions = computed(() =>
versions.value.map((version) => ({
value: version.version,
label: version.version
}))
)
const allVersionOptions = computed(() => [
...specialOptions.value,
...versionOptions.value
])
whenever(
() => nodePack,
() => {
void onNodePackChange()
},
{ deep: true, immediate: true }
() => nodePack.id,
() => startFetchVersions(),
{ deep: true }
)
const handleSubmit = async () => {

View File

@@ -6,12 +6,12 @@
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="loading"
:disabled="isInstalling"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<template v-if="loading">
<template v-if="isInstalling">
{{ loadingMessage ?? $t('g.loading') }}
</template>
<template v-else>
@@ -23,6 +23,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { inject, ref } from 'vue'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
const {
label,
@@ -30,7 +33,6 @@ const {
fullWidth = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
}>()
@@ -43,7 +45,10 @@ defineOptions({
inheritAttrs: false
})
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isInstalling.value = true
emit('action')
}
</script>

View File

@@ -5,10 +5,8 @@
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
"
severity="secondary"
:loading="isInstalling"
:loading-message="$t('g.installing')"
@action="installAllPacks"
@click="onClick"
/>
</template>
@@ -33,10 +31,6 @@ const { nodePacks } = defineProps<{
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isInstalling.value = true
}
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {

View File

@@ -4,10 +4,7 @@
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
>
<div class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar">
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
@@ -49,7 +46,7 @@
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -73,8 +70,6 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const isInstalling = ref(false)
@@ -108,17 +103,6 @@ const infoItems = computed<InfoItem[]>(() => [
: undefined
}
])
const { y } = useScroll(scrollContainer, {
eventListenerOptions: {
passive: true
}
})
const onNodePackChange = () => {
y.value = 0
}
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
</script>
<style scoped>
.hidden-scrollbar {

View File

@@ -31,7 +31,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
@@ -46,14 +46,7 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],
() => {
isAllInstalled.value = nodePacks.every((nodePack) =>
managerStore.isPackInstalled(nodePack.id)
)
},
{ immediate: true }
const isAllInstalled = computed(() =>
nodePacks.every((nodePack) => managerStore.isPackInstalled(nodePack.id))
)
</script>

View File

@@ -31,29 +31,28 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted } from 'vue'
import { computed } from 'vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const { getNodeDefs } = useComfyRegistryStore()
const comfyRegistryService = useComfyRegistryService()
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!pack.latest_version?.version) return []
const nodeDefs = await getNodeDefs.call({
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
version: pack.latest_version?.version
versionId: pack.latest_version?.id
})
return nodeDefs?.comfy_nodes ?? []
}
const { state: allNodeDefs } = useAsyncState(
@@ -70,8 +69,4 @@ const totalNodesCount = computed(() =>
0
)
)
onUnmounted(() => {
getNodeDefs.cancel()
})
</script>

View File

@@ -2,19 +2,15 @@
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes">
{{ $t('g.nodes') }}
</Tab>
<Tab value="description">{{ $t('g.description') }}</Tab>
<Tab value="nodes">{{ $t('g.nodes') }}</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" :node-names="nodeNames" />
<NodesTabPanel :node-pack="nodePack" />
</TabPanel>
</TabPanels>
</Tabs>
@@ -27,21 +23,15 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
defineProps<{
nodePack: components['schemas']['Node']
}>()
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack
return comfy_nodes ?? []
})
const activeTab = ref('description')
</script>

View File

@@ -1,9 +1,7 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">
{{ section.title }}
</div>
<div class="mb-1">{{ section.title }}</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
@@ -12,7 +10,10 @@
rel="noopener noreferrer"
class="flex items-center gap-2"
>
<i v-if="isGitHubLink(section.text)" class="pi pi-github text-base" />
<i
v-if="isGitHubLink(section.text)"
class="pi pi-github text-base"
></i>
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div v-if="!hasMarkdown" class="break-words" v-text="text" />
<div v-if="!hasMarkdown" v-text="text" class="break-words"></div>
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
@@ -16,7 +16,7 @@
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="px-1 py-0.5 rounded text-xs"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>

View File

@@ -1,92 +1,47 @@
<template>
<div class="flex flex-col gap-4 mt-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"
:key="createNodeDefKey(nodeDef)"
class="border rounded-lg p-4"
>
<NodePreview :node-def="nodeDef" class="!text-[.625rem] !min-w-full" />
</div>
</template>
<template v-else-if="isLoading">
<ProgressSpinner />
</template>
<template v-else-if="nodeNames.length">
<div v-for="node in nodeNames" :key="node" class="text-muted truncate">
{{ node }}
</div>
</template>
<template v-else>
<NoResultsPlaceholder
:title="$t('manager.noNodesFound')"
:message="$t('manager.noNodesFoundDescription')"
<div class="flex flex-col gap-4 mt-4 overflow-auto text-sm">
<div v-if="nodeDefs?.length">
<!-- TODO: when registry returns node defs, use them here -->
</div>
<div
v-else
v-for="i in 3"
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview
:node-def="placeholderNodeDef"
class="!text-[.625rem] !min-w-full"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, shallowRef, useId } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { components, operations } from '@/types/comfyRegistryTypes'
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
import { ComfyNodeDef } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { components } from '@/types/comfyRegistryTypes'
type ListComfyNodesResponse =
operations['ListComfyNodes']['responses'][200]['content']['application/json']['comfy_nodes']
const { nodePack, nodeNames } = defineProps<{
defineProps<{
nodePack: components['schemas']['Node']
nodeNames: string[]
nodeDefs?: components['schemas']['ComfyNode'][]
}>()
const { getNodeDefs } = useComfyRegistryStore()
const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
isLoading.value = true
const { id: packId } = nodePack
const version = nodePack.latest_version?.version
if (!packId || !version) {
registryNodeDefs.value = null
} else {
const response = await getNodeDefs.call({
packId,
version,
page: 1,
limit: 256
})
registryNodeDefs.value = response?.comfy_nodes ?? null
}
isLoading.value = false
// TODO: when registry returns node defs, use them here
const placeholderNodeDef: ComfyNodeDef = {
name: 'Sample Node',
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
input1: { name: 'Input 1', type: 'IMAGE' },
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0, is_list: false },
{ name: 'Output 2', type: 'MASK', index: 1, is_list: false }
],
category: 'Utility',
output_node: false,
python_module: 'nodes'
}
whenever(() => nodePack, fetchNodeDefs, { immediate: true, deep: true })
const toFrontendNodeDef = (nodeDef: components['schemas']['ComfyNode']) => {
try {
return registryToFrontendV2NodeDef(nodeDef, nodePack)
} catch (error) {
return null
}
}
const mappedNodeDefs = computed(() => {
if (!registryNodeDefs.value) return null
return registryNodeDefs.value
.map(toFrontendNodeDef)
.filter((nodeDef) => nodeDef !== null)
})
const createNodeDefKey = (nodeDef: components['schemas']['ComfyNode']) =>
`${nodeDef.category}${nodeDef.comfy_node_name ?? useId()}`
</script>

View File

@@ -10,7 +10,9 @@
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg p-0.5">
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>

View File

@@ -7,8 +7,10 @@
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
optionLabel="query"
class="w-full"
@complete="stubTrue"
@option-select="onOptionSelect"
:pt="{
pcInputText: {
root: {
@@ -20,9 +22,8 @@
style: 'display: none'
}
}"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
>
</AutoComplete>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
@@ -56,10 +57,7 @@ import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import type { PackField, SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { searchResults } = defineProps<{
@@ -69,9 +67,7 @@ const { searchResults } = defineProps<{
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('sortField', {
default: SortableAlgoliaField.Downloads
})
const sortField = defineModel<PackField>('sortField', { default: 'downloads' })
const { t } = useI18n()
@@ -79,12 +75,11 @@ const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
{ id: SortableAlgoliaField.Name, label: t('g.name') }
const sortOptions: SearchOption<PackField>[] = [
{ id: 'downloads', label: t('manager.sort.downloads') },
{ id: 'name', label: t('g.name') },
{ id: 'rating', label: t('manager.sort.rating') },
{ id: 'category', label: t('g.category') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },

View File

@@ -2,10 +2,11 @@
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
:model-value="modelValue"
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:options="options"
option-label="label"
option-value="id"
optionLabel="label"
optionValue="id"
class="min-w-[6rem] border-none bg-transparent shadow-none"
:pt="{
input: { class: 'py-0 px-1 border-none' },
@@ -13,7 +14,6 @@
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
@update:model-value="$emit('update:modelValue', $event)"
/>
</div>
</template>

View File

@@ -7,44 +7,52 @@
<div class="w-full px-4 py-3 flex justify-between items-center border-b">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
<Skeleton shape="circle" width="1.5rem" height="1.5rem"></Skeleton>
</div>
<Skeleton width="5rem" height="1rem" class="ml-2" />
<Skeleton width="5rem" height="1rem" class="ml-2"></Skeleton>
</div>
<Skeleton width="4rem" height="1.75rem" border-radius="0.75rem" />
<Skeleton width="4rem" height="1.75rem" borderRadius="0.75rem"></Skeleton>
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="flex-shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" border-radius="0.5rem" />
<Skeleton width="4rem" height="4rem" borderRadius="0.5rem"></Skeleton>
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2" />
<Skeleton width="80%" height="1rem" class="mb-2"></Skeleton>
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1" />
<Skeleton width="95%" height="0.75rem" class="mb-1" />
<Skeleton width="90%" height="0.75rem" />
<Skeleton width="100%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="95%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="90%" height="0.75rem"></Skeleton>
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton width="4rem" height="1.5rem" border-radius="0.75rem" />
<Skeleton width="5rem" height="1.5rem" border-radius="0.75rem" />
<Skeleton
width="4rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
<Skeleton
width="5rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center border-t">
<Skeleton width="4rem" height="0.8rem" />
<Skeleton width="6rem" height="0.8rem" />
<Skeleton width="4rem" height="0.8rem"></Skeleton>
<Skeleton width="6rem" height="0.8rem"></Skeleton>
</div>
</div>
</template>

View File

@@ -1,8 +1,6 @@
<template>
<PanelTemplate value="About" class="about-container">
<h2 class="text-2xl font-bold mb-2">
{{ $t('g.about') }}
</h2>
<h2 class="text-2xl font-bold mb-2">{{ $t('g.about') }}</h2>
<div class="space-y-2">
<a
v-for="badge in aboutPanelStore.badges"
@@ -15,7 +13,7 @@
>
<Tag class="mr-2">
<template #icon>
<i :class="[badge.icon, 'mr-2 text-xl']" />
<i :class="[badge.icon, 'mr-2 text-xl']"></i>
</template>
{{ badge.label }}
</Tag>

View File

@@ -6,11 +6,11 @@
</div>
<div class="actions">
<Select
v-model="activePaletteId"
class="w-44"
v-model="activePaletteId"
:options="palettes"
option-label="name"
option-value="id"
optionLabel="name"
optionValue="id"
/>
<Button
icon="pi pi-file-export"
@@ -29,8 +29,8 @@
severity="danger"
text
:title="$t('g.delete')"
:disabled="!colorPaletteStore.isCustomPalette(activePaletteId)"
@click="colorPaletteService.deleteCustomColorPalette(activePaletteId)"
:disabled="!colorPaletteStore.isCustomPalette(activePaletteId)"
/>
</div>
</div>
@@ -55,7 +55,7 @@ const { palettes, activePaletteId } = storeToRefs(colorPaletteStore)
const importCustomPalette = async () => {
const palette = await colorPaletteService.importColorPalette()
if (palette) {
await settingStore.set('Comfy.ColorPalette', palette.id)
settingStore.set('Comfy.ColorPalette', palette.id)
}
}
</script>

View File

@@ -1,187 +0,0 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<div class="flex flex-col h-full">
<h2 class="text-2xl font-bold mb-2">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex justify-between items-center">
<div v-if="balanceLoading" class="flex items-center gap-1">
<div class="flex items-center gap-2">
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
</div>
<div class="flex-1"></div>
<Skeleton width="8rem" height="2rem" />
</div>
<div v-else class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<div class="text-3xl font-bold">{{ formattedBalance }}</div>
</div>
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
/>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
icon="pi pi-refresh"
text
size="small"
severity="secondary"
@click="() => authStore.fetchBalance()"
/>
</div>
</div>
<div class="flex justify-between items-center mt-8">
<Button
:label="$t('credits.creditsHistory')"
text
severity="secondary"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="handleCreditsHistoryClick"
/>
</div>
<template v-if="creditHistory.length > 0">
<div class="flex-grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-base font-medium text-center',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<div class="flex flex-row gap-2">
<Button
:label="$t('credits.faqs')"
text
severity="secondary"
icon="pi pi-question-circle"
@click="handleFaqClick"
/>
<Button
:label="$t('credits.messageSupport')"
text
severity="secondary"
icon="pi pi-comments"
@click="handleMessageSupport"
/>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import Tag from 'primevue/tag'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
const handlePurchaseCreditsClick = () => {
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
const response = await authStore.accessBillingPortal()
if (!response) return
const { billing_portal_url } = response
if (billing_portal_url) {
window.open(billing_portal_url, '_blank')
}
}
const handleMessageSupport = () => {
dialogService.showIssueReportDialog({
title: t('issueReport.contactSupportTitle'),
subtitle: t('issueReport.contactSupportDescription'),
panelProps: {
errorType: 'BillingSupport',
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
}
})
}
const handleFaqClick = () => {
window.open('https://www.comfy.org/faq', '_blank')
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -10,7 +10,7 @@
<div>
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
</div>
<Button icon="pi pi-sign-out" text @click="logout" />
<Button icon="pi pi-sign-out" @click="logout" text />
</div>
</Message>
</template>
@@ -22,8 +22,8 @@ import Message from 'primevue/message'
import { useUserStore } from '@/stores/userStore'
const userStore = useUserStore()
const logout = async () => {
await userStore.logout()
const logout = () => {
userStore.logout()
window.location.reload()
}
</script>

Some files were not shown because too many files have changed in this diff Show More