mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 03:37:48 +00:00
Compare commits
19 Commits
feature/sh
...
split/auto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c5597d70 | ||
|
|
85f9a5347d | ||
|
|
f2d632385b | ||
|
|
fa2e174d81 | ||
|
|
a898e39d20 | ||
|
|
2ef341dcd8 | ||
|
|
1815c7f7a4 | ||
|
|
287b9eb980 | ||
|
|
06b0471257 | ||
|
|
8120142f49 | ||
|
|
3164e6ab61 | ||
|
|
731512c655 | ||
|
|
c0ad1e98c2 | ||
|
|
bd9fab2d2f | ||
|
|
c7fe6a23ec | ||
|
|
df9b5bfa0a | ||
|
|
a6b7ce11aa | ||
|
|
d3b100be8d | ||
|
|
54b0c10148 |
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,3 +40,6 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
158
apps/website/e2e/learning.spec.ts
Normal file
158
apps/website/e2e/learning.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
129
apps/website/scripts/validate-jsonld.ts
Normal file
129
apps/website/scripts/validate-jsonld.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -15,8 +16,7 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -11,6 +14,8 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -20,15 +25,32 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
@@ -73,7 +74,7 @@ export function getBannerData(
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
href: localizeHref(link.href, locale),
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
|
||||
53
apps/website/src/config/pricing.ts
Normal file
53
apps/website/src/config/pricing.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
23
apps/website/src/config/routes.test.ts
Normal file
23
apps/website/src/config/routes.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -47,13 +47,26 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'enterpriseMsa'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -69,15 +82,19 @@ export const externalLinks = {
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||
@@ -1872,6 +1872,10 @@ const translations = {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.installMcp': {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
@@ -1909,21 +1913,27 @@ const translations = {
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.step1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
@@ -2180,6 +2190,13 @@ const translations = {
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
|
||||
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
|
||||
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
@@ -4051,7 +4068,6 @@ const translations = {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
@@ -4147,10 +4163,6 @@ const translations = {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
|
||||
@@ -7,14 +7,17 @@ import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import { isHrefActive } from '../composables/useCurrentPath'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -22,6 +25,10 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -30,52 +37,54 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
// A promo never advertises the page you are already on, so the banner is
|
||||
// suppressed when its CTA points at the current path.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVisible =
|
||||
evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -117,10 +126,7 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -140,7 +146,6 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
|
||||
@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,41 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../config/pricing'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,44 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'en')
|
||||
const description = t('cloudNodes.meta.description', 'en')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import FormSection from '../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact — Comfy">
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from "../utils/jsonLd";
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
comfyUiApplicationNode(siteUrl),
|
||||
comfyUiSourceCodeNode(siteUrl),
|
||||
]}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
|
||||
@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: displayName,
|
||||
url,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
})
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
@@ -97,54 +79,44 @@ const faqJsonLd = {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('models.breadcrumb.models', locale),
|
||||
url: absoluteUrl(Astro.site, '/p/supported-models'),
|
||||
},
|
||||
{ name: displayName },
|
||||
]}
|
||||
extraJsonLd={[software, faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
|
||||
@@ -2,10 +2,29 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const modelList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
models.map((model) => ({
|
||||
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
|
||||
})),
|
||||
)
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[modelList]}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
|
||||
@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,44 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../../config/pricing'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,47 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'zh-CN')
|
||||
const description = t('cloudNodes.meta.description', 'zh-CN')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,28 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import FormSection from '../../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="联系我们 — Comfy">
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
|
||||
@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
|
||||
@@ -9,16 +9,25 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
|
||||
212
apps/website/src/utils/jsonLd.test.ts
Normal file
212
apps/website/src/utils/jsonLd.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
import type { JsonLdGraph } from './jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPageGraph,
|
||||
collectGraphIds,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
productNode,
|
||||
softwareApplicationNode
|
||||
} from './jsonLd'
|
||||
|
||||
const siteUrl = 'https://comfy.org'
|
||||
const site = new URL('https://comfy.org/')
|
||||
|
||||
function typeNames(graph: JsonLdGraph): string[] {
|
||||
return graph['@graph'].map((node) => node['@type'])
|
||||
}
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('resolves internal paths to their trailing-slash canonical form', () => {
|
||||
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
|
||||
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
|
||||
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageContext', () => {
|
||||
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
|
||||
expect(pageContext(site, '/about/', undefined)).toEqual({
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
url: 'https://comfy.org/about/'
|
||||
})
|
||||
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemListNode', () => {
|
||||
it('counts items and omits per-item names when not supplied', () => {
|
||||
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
|
||||
{ url: 'https://jobs.example/1' },
|
||||
{ url: 'https://jobs.example/2', name: 'Designer' }
|
||||
])
|
||||
expect(node.numberOfItems).toBe(2)
|
||||
const items = node.itemListElement as Record<string, unknown>[]
|
||||
expect('name' in items[0]).toBe(false)
|
||||
expect(items[1].name).toBe('Designer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('softwareApplicationNode', () => {
|
||||
it('claims Comfy Org as author and publisher only when first-party', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: jsonLdId(siteUrl, 'software'),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
isFree: true
|
||||
})
|
||||
const orgRef = { '@id': organizationId(siteUrl) }
|
||||
expect(node.author).toEqual(orgRef)
|
||||
expect(node.publisher).toEqual(orgRef)
|
||||
expect(node.offers).toEqual({
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: orgRef
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name Comfy Org as seller on a third-party free offer', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isFree: true
|
||||
})
|
||||
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
|
||||
})
|
||||
|
||||
it('credits a known third-party author without claiming to publish it', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
authorName: 'Jane Dev'
|
||||
})
|
||||
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims no author or publisher for third-party software with no author', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.author).toBeUndefined()
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameAs encyclopedic references', () => {
|
||||
it('links the organization to its Wikidata entity', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/`, name: 'Home' }
|
||||
)
|
||||
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
|
||||
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
|
||||
})
|
||||
|
||||
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.sameAs).toEqual([
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sameAs for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.sameAs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('productNode', () => {
|
||||
it('gives every offer a currency and price', () => {
|
||||
const node = productNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/pricing/#product',
|
||||
name: 'Comfy Cloud',
|
||||
url: 'https://comfy.org/cloud/pricing/',
|
||||
offers: [{ name: 'Standard', price: '20' }]
|
||||
})
|
||||
const offers = node.offers as Record<string, unknown>[]
|
||||
expect(offers[0].price).toBe('20')
|
||||
expect(offers[0].priceCurrency).toBe('USD')
|
||||
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfyUiSourceCodeNode', () => {
|
||||
it('links the source code to the ComfyUI application via targetProduct', () => {
|
||||
const node = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{
|
||||
url,
|
||||
name: 'Pricing',
|
||||
type: 'CollectionPage',
|
||||
mainEntityId: jsonLdId(url, 'itemlist'),
|
||||
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
|
||||
},
|
||||
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
|
||||
)
|
||||
|
||||
it('always includes the site-wide organization, website and page entity', () => {
|
||||
expect(typeNames(graph)).toContain('Organization')
|
||||
expect(typeNames(graph)).toContain('WebSite')
|
||||
expect(typeNames(graph)).toContain('CollectionPage')
|
||||
})
|
||||
|
||||
it('produces a graph where every @id reference resolves', () => {
|
||||
const { defined, references } = collectGraphIds(graph)
|
||||
for (const reference of references) {
|
||||
expect(defined.has(reference)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeJsonLd on a built graph', () => {
|
||||
it('neutralizes a </script> breakout in a page name', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
|
||||
)
|
||||
const serialized = escapeJsonLd(graph)
|
||||
expect(serialized).not.toContain('</script>')
|
||||
expect(serialized).toContain('\\u003c')
|
||||
})
|
||||
})
|
||||
377
apps/website/src/utils/jsonLd.ts
Normal file
377
apps/website/src/utils/jsonLd.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export type WebPageType =
|
||||
| 'WebPage'
|
||||
| 'AboutPage'
|
||||
| 'ContactPage'
|
||||
| 'CollectionPage'
|
||||
|
||||
export interface Crumb {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const sameAs = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin,
|
||||
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
|
||||
externalLinks.wikidataComfyOrg
|
||||
]
|
||||
|
||||
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
|
||||
const comfyUiSameAs = [
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
]
|
||||
|
||||
function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function absoluteUrl(site: URL | undefined, path: string): string {
|
||||
const resolved = new URL(path, site ?? 'https://comfy.org').href
|
||||
return resolved.endsWith('/') ? resolved : `${resolved}/`
|
||||
}
|
||||
|
||||
export function pageContext(
|
||||
site: URL | undefined,
|
||||
pathname: string,
|
||||
currentLocale: string | undefined
|
||||
): PageContext & { url: string } {
|
||||
return {
|
||||
siteUrl: siteUrlFrom(site),
|
||||
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
url: absoluteUrl(site, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonLdId(pageUrl: string, fragment: string): string {
|
||||
return `${pageUrl}#${fragment}`
|
||||
}
|
||||
|
||||
export function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
|
||||
}
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': jsonLdId(pageUrl, 'breadcrumb'),
|
||||
itemListElement: crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return isLast || !crumb.url
|
||||
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
|
||||
: {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: crumb.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListNode(
|
||||
pageUrl: string,
|
||||
name: string,
|
||||
items: { url: string; name?: string }[]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': jsonLdId(pageUrl, 'itemlist'),
|
||||
name,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
...(item.name ? { name: item.name } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
interface WebPageInput {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
url: string
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
|
||||
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
|
||||
return {
|
||||
'@type': type,
|
||||
'@id': jsonLdId(input.url, 'webpage'),
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isPartOf: { '@id': websiteId(input.siteUrl) },
|
||||
primaryImageOfPage: input.imageUrl
|
||||
? { '@type': 'ImageObject', url: input.imageUrl }
|
||||
: undefined,
|
||||
breadcrumb: hasCrumbs
|
||||
? { '@id': jsonLdId(input.url, 'breadcrumb') }
|
||||
: undefined,
|
||||
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
|
||||
inLanguage: input.locale
|
||||
}
|
||||
}
|
||||
|
||||
export interface SoftwareAppInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
applicationCategory: string
|
||||
firstParty?: boolean
|
||||
applicationSubCategory?: string
|
||||
description?: string
|
||||
operatingSystem?: string
|
||||
image?: string
|
||||
softwareVersion?: string
|
||||
license?: string
|
||||
codeRepository?: string
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
const orgRef = { '@id': organizationId(input.siteUrl) }
|
||||
const author = input.firstParty
|
||||
? orgRef
|
||||
: input.authorName
|
||||
? { '@type': 'Person', name: input.authorName }
|
||||
: undefined
|
||||
return {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
applicationCategory: input.applicationCategory,
|
||||
applicationSubCategory: input.applicationSubCategory,
|
||||
description: input.description,
|
||||
operatingSystem: input.operatingSystem,
|
||||
image: input.image,
|
||||
softwareVersion: input.softwareVersion,
|
||||
license: input.license,
|
||||
codeRepository: input.codeRepository,
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: input.firstParty ? orgRef : undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceCodeInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
codeRepository: string
|
||||
programmingLanguage?: string
|
||||
targetProductId?: string
|
||||
}
|
||||
|
||||
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
codeRepository: input.codeRepository,
|
||||
programmingLanguage: input.programmingLanguage,
|
||||
targetProduct: input.targetProductId
|
||||
? { '@id': input.targetProductId }
|
||||
: undefined,
|
||||
author: { '@id': organizationId(input.siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: comfyUiSoftwareId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
targetProductId: comfyUiSoftwareId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
interface OfferInput {
|
||||
name: string
|
||||
price: string | number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
offers: OfferInput[]
|
||||
}
|
||||
|
||||
export function productNode(input: ProductInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Product',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
brand: { '@id': organizationId(input.siteUrl) },
|
||||
offers: input.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
url: offer.url,
|
||||
seller: { '@id': organizationId(input.siteUrl) },
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageGraphInput {
|
||||
url: string
|
||||
name: string
|
||||
type?: WebPageType
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
export function buildPageGraph(
|
||||
ctx: PageContext,
|
||||
page: PageGraphInput,
|
||||
...extraNodes: (JsonLdNode | null | undefined)[]
|
||||
): JsonLdGraph {
|
||||
const { type = 'WebPage', ...rest } = page
|
||||
const input: WebPageInput = {
|
||||
...rest,
|
||||
siteUrl: ctx.siteUrl,
|
||||
locale: ctx.locale
|
||||
}
|
||||
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
|
||||
return buildGraph(
|
||||
organizationNode(ctx.siteUrl),
|
||||
websiteNode(ctx.siteUrl),
|
||||
webPageNode(input, type),
|
||||
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
|
||||
...extraNodes
|
||||
)
|
||||
}
|
||||
|
||||
export function collectGraphIds(value: unknown): {
|
||||
defined: Set<string>
|
||||
references: string[]
|
||||
} {
|
||||
const defined = new Set<string>()
|
||||
const references: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>
|
||||
const id = record['@id']
|
||||
if (typeof id === 'string') {
|
||||
if (Object.keys(record).length === 1) references.push(id)
|
||||
else defined.add(id)
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return { defined, references }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
|
||||
@@ -33,6 +33,27 @@ export function member(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
|
||||
* this the mint fails and auth cannot resolve the active workspace.
|
||||
*/
|
||||
export async function mockWorkspaceTokenMint(
|
||||
page: Page,
|
||||
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
|
||||
) {
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
@@ -46,17 +67,7 @@ export async function mockWorkspace(
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, ws)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
@@ -81,6 +85,7 @@ async function mockCloudBoot(
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -138,10 +138,6 @@ test.describe('Help Center', () => {
|
||||
)
|
||||
|
||||
await helpCenter.mockReleases(releases)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Notification.ShowVersionUpdates',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
await helpCenter.open()
|
||||
|
||||
@@ -161,10 +157,6 @@ test.describe('Help Center', () => {
|
||||
|
||||
await helpCenter.mockReleases([release])
|
||||
await helpCenter.stubDocsPage()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Notification.ShowVersionUpdates',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
await helpCenter.open()
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -22,12 +22,6 @@ test.describe('Release Notifications', () => {
|
||||
test('should show help center with release information', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Version-update notifications default off on local installs
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Notification.ShowVersionUpdates',
|
||||
true
|
||||
)
|
||||
|
||||
// Mock release API with test data instead of empty array
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
@@ -78,10 +72,10 @@ test.describe('Release Notifications', () => {
|
||||
await expect(helpMenu).toBeHidden()
|
||||
})
|
||||
|
||||
test('should hide "What\'s New" section by default on local installs', async ({
|
||||
test('should not show release notifications when mocked (default behavior)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Use default setup (mockReleases: true); notifications default off locally
|
||||
// Use default setup (mockReleases: true)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Open help center
|
||||
@@ -93,11 +87,16 @@ test.describe('Release Notifications', () => {
|
||||
const helpMenu = comfyPage.page.locator('.help-center-menu')
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// "What's New?" section is hidden because the setting defaults off
|
||||
// Verify "What's New?" section shows no releases
|
||||
const whatsNewSection = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.whatsNewSection
|
||||
)
|
||||
await expect(whatsNewSection).toBeHidden()
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show "No recent releases" message
|
||||
await expect(
|
||||
whatsNewSection.locator('text=No recent releases')
|
||||
).toBeVisible()
|
||||
|
||||
// Should not show any popups or toasts
|
||||
await expect(comfyPage.page.locator('.whats-new-popup')).toBeHidden()
|
||||
@@ -107,12 +106,6 @@ test.describe('Release Notifications', () => {
|
||||
})
|
||||
|
||||
test('should handle release API errors gracefully', async ({ comfyPage }) => {
|
||||
// Version-update notifications default off on local installs
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Notification.ShowVersionUpdates',
|
||||
true
|
||||
)
|
||||
|
||||
// Mock API to return an error
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
|
||||
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 11. Derived Credential Lifecycle for Cloud Auth
|
||||
|
||||
Date: 2026-07-09
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
Cloud authentication derives several short-lived credentials from a single
|
||||
source of truth — the Firebase identity (ID token):
|
||||
|
||||
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
|
||||
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
|
||||
(`useSessionCookie`),
|
||||
- and consumer state gated on those credentials, such as **subscription status**
|
||||
(`useSubscription`).
|
||||
|
||||
A recurring class of production bugs traces back to how these derived credentials
|
||||
are kept fresh rather than to any single code path:
|
||||
|
||||
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
|
||||
Its refresh relies on a `setTimeout` timer that browsers throttle in background
|
||||
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
|
||||
call 401s until reload.
|
||||
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
|
||||
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
|
||||
personal Firebase token, so requests authenticated as the wrong identity.
|
||||
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
|
||||
burst on wake/network-swap fans out into concurrent, undeduped subscription
|
||||
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
|
||||
return 401/empty, flapping `subscriptionStatus` and the run button.
|
||||
|
||||
These are not independent defects. They are symptoms of one design shape: **each
|
||||
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
|
||||
one-shot events rather than the source identity, with no coalescing of concurrent
|
||||
refreshes and with silent fallback to a different identity or a stale value on
|
||||
failure.** Any credential built this way can go stale, stampede, or downgrade.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat every derived credential as a pure function of the Firebase identity, and
|
||||
require all of them to obey the same lifecycle invariants. New auth code must
|
||||
satisfy these; existing code migrates toward them incrementally.
|
||||
|
||||
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
|
||||
JWT and session cookie are derivations of it, never independent state that can
|
||||
drift from it.
|
||||
|
||||
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
|
||||
or a definitive failure — never a known-expired one. Validity is checked at the
|
||||
point of use (expiry-aware), not assumed because a background timer _should_
|
||||
have refreshed. Timers may be an optimization, never the guarantee.
|
||||
|
||||
3. **Single-flight.** Concurrent requests for the same credential share one
|
||||
in-flight mint/refresh. A refresh burst collapses to a single network call.
|
||||
|
||||
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
|
||||
obtained, fail the request. Never silently substitute a different identity or
|
||||
scope (e.g. personal token for a workspace request).
|
||||
|
||||
5. **Bounded reactive retry.** Invalidation is driven by the source identity
|
||||
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
|
||||
derived credential triggers at most one re-mint and one retry, then surfaces
|
||||
the error.
|
||||
|
||||
6. **Explicit scope.** A credential names the identity/workspace it is for.
|
||||
Coalesced results are verified against the requested scope before use.
|
||||
|
||||
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
|
||||
single-flight, fail-closed, and reconciles a revoked workspace instead of
|
||||
downgrading; subscription-status and session-cookie creation are now
|
||||
single-flight so a refresh burst can no longer flap them. It intentionally does
|
||||
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
|
||||
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
|
||||
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
|
||||
re-mint + single retry).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- **Layer more defensive checks per call site.** Rejected: this is what produced
|
||||
the current state — correctness that depends on every caller remembering to
|
||||
guard is the defect, not the fix.
|
||||
- **A single reactive credential store subscribing to Firebase, replacing all
|
||||
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
|
||||
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
|
||||
these invariants incrementally instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Whole categories of failure become structurally hard rather than individually
|
||||
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
|
||||
requests (4).
|
||||
- New auth code has a single checklist to satisfy, and reviewers a single rubric
|
||||
to apply.
|
||||
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
|
||||
reasoning about auth changes.
|
||||
|
||||
### Negative
|
||||
|
||||
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
|
||||
transient conditions now show errors instead of degrading quietly, so
|
||||
transient-vs-permanent classification must be correct.
|
||||
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
|
||||
lazy and a backgrounded tab still relies on the next read to heal, leaving a
|
||||
visible gap against FE-613's reactive ideal.
|
||||
- Existing lifecycles remain non-uniform during migration, so the mental model is
|
||||
"target vs. current" until the reactive credential store exists.
|
||||
|
||||
## Notes
|
||||
|
||||
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
|
||||
shares the philosophy of designing invariants that make illegal states
|
||||
unrepresentable rather than guarding against them per call site.
|
||||
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.
|
||||
@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration in the workspace-settings stacked PRs: consumed by
|
||||
// split/member-auditing + split/allowlist; each consumer removes its entry
|
||||
'src/components/ui/pagination/Pagination.vue',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.6",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -414,15 +414,15 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
it('returns true for image/video/audio/3D/text', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
expect(isPreviewableMediaType('text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
it('returns false for other', () => {
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -677,12 +677,7 @@ export function getMediaTypeFromFilename(
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
return mediaType !== 'other'
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
3
public/assets/images/gemini.svg
Normal file
3
public/assets/images/gemini.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
|
||||
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
4
public/assets/images/runway.svg
Normal file
4
public/assets/images/runway.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
|
||||
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
|
||||
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
/>
|
||||
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
|
||||
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
|
||||
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultText from './ResultText.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<article
|
||||
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
|
||||
>
|
||||
<span v-if="hasError" class="text-muted-foreground">
|
||||
{{ $t('g.textFailedToLoad') }}
|
||||
</span>
|
||||
<template v-else>{{ textContent }}</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { result } = defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const { textContent, hasError } = useTextFileContent(() => result)
|
||||
</script>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -33,8 +33,7 @@ export enum ServerFeatureFlag {
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
SHOW_VERSION_UPDATES = 'show_version_updates'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
71
src/composables/useTextFileContent.test.ts
Normal file
71
src/composables/useTextFileContent.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
|
||||
function stubFetch(response: Partial<Response> | Error) {
|
||||
const mock =
|
||||
response instanceof Error
|
||||
? vi.fn().mockRejectedValue(response)
|
||||
: vi.fn().mockResolvedValue(response)
|
||||
vi.stubGlobal('fetch', mock)
|
||||
return mock
|
||||
}
|
||||
|
||||
describe(useTextFileContent, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns inline content without fetching', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent } = useTextFileContent(() => ({
|
||||
content: 'inline text',
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches text from the url when no inline content is present', async () => {
|
||||
const fetchMock = stubFetch({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('fetched text')
|
||||
})
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
|
||||
expect(hasError.value).toBe(false)
|
||||
})
|
||||
|
||||
it('flags an error for a non-ok response', async () => {
|
||||
stubFetch({ ok: false })
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/missing.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
expect(textContent.value).toBe('')
|
||||
})
|
||||
|
||||
it('flags an error when the fetch rejects', async () => {
|
||||
stubFetch(new Error('network down'))
|
||||
const { hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
})
|
||||
|
||||
it('resolves empty content when there is no source', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent, isLoading } = useTextFileContent(() => undefined)
|
||||
|
||||
await vi.waitFor(() => expect(isLoading.value).toBe(false))
|
||||
expect(textContent.value).toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
40
src/composables/useTextFileContent.ts
Normal file
40
src/composables/useTextFileContent.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface TextSource {
|
||||
content?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function useTextFileContent(
|
||||
source: MaybeRefOrGetter<TextSource | undefined>
|
||||
) {
|
||||
const isLoading = ref(false)
|
||||
const hasError = ref(false)
|
||||
|
||||
const textContent = computedAsync(
|
||||
async () => {
|
||||
hasError.value = false
|
||||
const { content, url } = toValue(source) ?? {}
|
||||
if (content !== undefined) return content
|
||||
if (!url) return ''
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
hasError.value = true
|
||||
return ''
|
||||
}
|
||||
return await response.text()
|
||||
},
|
||||
'',
|
||||
{
|
||||
evaluating: isLoading,
|
||||
onError: () => {
|
||||
hasError.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { textContent, isLoading, hasError }
|
||||
}
|
||||
@@ -21,6 +21,7 @@ if (!isCloud) {
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './previewAny'
|
||||
import './saveText'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const { addTextPreviewWidgets, updateTextPreviewWidgets } = vi.hoisted(() => ({
|
||||
addTextPreviewWidgets: vi.fn(),
|
||||
updateTextPreviewWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/textPreviewWidgets', () => ({
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
}))
|
||||
|
||||
const capturedExtensions: ComfyExtension[] = []
|
||||
|
||||
@@ -12,103 +23,51 @@ vi.mock('@/services/extensionService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: {} }))
|
||||
type BeforeRegister = NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
element: { readOnly: boolean }
|
||||
callback?: (value: unknown) => void
|
||||
value: unknown
|
||||
hidden: boolean
|
||||
label: string
|
||||
serialize?: boolean
|
||||
}
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
|
||||
const createdWidgets: MockWidget[] = []
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<BeforeRegister>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<BeforeRegister>[1]
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<BeforeRegister>[2]
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/widgets', () => {
|
||||
const create =
|
||||
(kind: string) =>
|
||||
(
|
||||
node: { widgets?: MockWidget[] },
|
||||
name: string,
|
||||
_info: unknown,
|
||||
_app: unknown
|
||||
) => {
|
||||
const widget: MockWidget = {
|
||||
name,
|
||||
options: {},
|
||||
element: { readOnly: false },
|
||||
value: kind === 'BOOLEAN' ? false : '',
|
||||
hidden: false,
|
||||
label: ''
|
||||
}
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
createdWidgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
return {
|
||||
ComfyWidgets: {
|
||||
MARKDOWN: create('MARKDOWN'),
|
||||
STRING: create('STRING'),
|
||||
BOOLEAN: create('BOOLEAN')
|
||||
}
|
||||
const node = {} as LGraphNode
|
||||
const proto = nodeType.prototype as {
|
||||
onNodeCreated?: () => void
|
||||
onExecuted?: (message: { text?: string }) => void
|
||||
}
|
||||
})
|
||||
return { node, proto }
|
||||
}
|
||||
|
||||
describe('PreviewAny extension', () => {
|
||||
beforeEach(async () => {
|
||||
capturedExtensions.length = 0
|
||||
createdWidgets.length = 0
|
||||
addTextPreviewWidgets.mockClear()
|
||||
updateTextPreviewWidgets.mockClear()
|
||||
vi.resetModules()
|
||||
await import('./previewAny')
|
||||
})
|
||||
|
||||
async function setupNode() {
|
||||
const ext = capturedExtensions.find((e) => e.name === 'Comfy.PreviewAny')
|
||||
expect(ext).toBeDefined()
|
||||
it('adds the shared text preview widgets on node creation', async () => {
|
||||
const { node, proto } = await setupNode()
|
||||
|
||||
const nodeType = { prototype: {} } as unknown as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[0]
|
||||
const nodeData = { name: 'PreviewAny' } as Parameters<
|
||||
NonNullable<ComfyExtension['beforeRegisterNodeDef']>
|
||||
>[1]
|
||||
|
||||
await ext!.beforeRegisterNodeDef!(
|
||||
nodeType,
|
||||
nodeData,
|
||||
{} as Parameters<NonNullable<ComfyExtension['beforeRegisterNodeDef']>>[2]
|
||||
)
|
||||
|
||||
const node: { widgets?: MockWidget[] } = {}
|
||||
const proto = nodeType.prototype as { onNodeCreated?: () => void }
|
||||
proto.onNodeCreated!.call(node)
|
||||
return node
|
||||
}
|
||||
|
||||
it('excludes preview widgets from the API prompt to prevent re-execution', async () => {
|
||||
await setupNode()
|
||||
expect(addTextPreviewWidgets).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
const previewMarkdown = createdWidgets.find(
|
||||
(w) => w.name === 'preview_markdown'
|
||||
)
|
||||
const previewText = createdWidgets.find((w) => w.name === 'preview_text')
|
||||
const previewMode = createdWidgets.find((w) => w.name === 'previewMode')
|
||||
it('updates the preview with executed text', async () => {
|
||||
const { node, proto } = await setupNode()
|
||||
const message = { text: 'hello' }
|
||||
|
||||
expect(previewMarkdown).toBeDefined()
|
||||
expect(previewText).toBeDefined()
|
||||
expect(previewMode).toBeDefined()
|
||||
proto.onExecuted!.call(node, message)
|
||||
|
||||
// widget.options.serialize === false is what executionUtil.graphToPrompt
|
||||
// checks to exclude a widget from the API prompt sent to the backend.
|
||||
// Without this, post-execution widget value updates (the rendered preview
|
||||
// text) get serialized as inputs, change the cache signature, and cause
|
||||
// the node to re-execute on the next prompt.
|
||||
expect(previewMarkdown!.options.serialize).toBe(false)
|
||||
expect(previewText!.options.serialize).toBe(false)
|
||||
expect(previewMode!.options.serialize).toBe(false)
|
||||
expect(updateTextPreviewWidgets).toHaveBeenCalledWith(node, message)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,14 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
|
||||
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
|
||||
*/
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type DOMWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PreviewAny',
|
||||
@@ -16,82 +19,24 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (nodeData.name === 'PreviewAny') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
if (nodeData.name !== 'PreviewAny') return
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, [])
|
||||
addTextPreviewWidgets(this)
|
||||
}
|
||||
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview_markdown',
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showValueWidgetPlain = ComfyWidgets['STRING'](
|
||||
this,
|
||||
'preview_text',
|
||||
['STRING', { multiline: true }],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
const showAsPlaintextWidget = ComfyWidgets['BOOLEAN'](
|
||||
this,
|
||||
'previewMode',
|
||||
[
|
||||
'BOOLEAN',
|
||||
{ label_on: 'Markdown', label_off: 'Plaintext', default: false }
|
||||
],
|
||||
app
|
||||
)
|
||||
|
||||
showAsPlaintextWidget.widget.callback = (value: boolean) => {
|
||||
showValueWidget.hidden = !value
|
||||
showValueWidget.options.hidden = !value
|
||||
showValueWidgetPlain.hidden = value
|
||||
showValueWidgetPlain.options.hidden = value
|
||||
}
|
||||
|
||||
showValueWidget.label = 'Preview'
|
||||
showValueWidget.hidden = true
|
||||
showValueWidget.options.hidden = true
|
||||
showValueWidget.options.read_only = true
|
||||
showValueWidget.options.serialize = false
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.serialize = false
|
||||
|
||||
showValueWidgetPlain.label = 'Preview'
|
||||
showValueWidgetPlain.hidden = false
|
||||
showValueWidgetPlain.options.hidden = false
|
||||
showValueWidgetPlain.options.read_only = true
|
||||
showValueWidgetPlain.options.serialize = false
|
||||
showValueWidgetPlain.element.readOnly = true
|
||||
showValueWidgetPlain.serialize = false
|
||||
|
||||
// The previewMode toggle is a frontend-only display preference and
|
||||
// is not declared in the backend INPUT_TYPES, so it must not be
|
||||
// serialized into the API prompt (would alter the cache signature).
|
||||
showAsPlaintextWidget.widget.options.serialize = false
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted === null || onExecuted === void 0
|
||||
? void 0
|
||||
: onExecuted.apply(this, [message])
|
||||
|
||||
const previewWidgets =
|
||||
this.widgets?.filter((w) => w.name.startsWith('preview_')) ?? []
|
||||
|
||||
for (const previewWidget of previewWidgets) {
|
||||
const text = message.text ?? ''
|
||||
previewWidget.value = Array.isArray(text)
|
||||
? (text?.join('\n\n') ?? '')
|
||||
: text
|
||||
}
|
||||
}
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
},
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
|
||||
if (node?.type === 'PreviewAny') updateTextPreviewWidgets(node, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
24
src/extensions/core/saveText.ts
Normal file
24
src/extensions/core/saveText.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
addTextPreviewWidgets,
|
||||
updateTextPreviewWidgets
|
||||
} from '@/extensions/core/textPreviewWidgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.saveText',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== 'SaveText') return
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply(this, [])
|
||||
addTextPreviewWidgets(this)
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, [message])
|
||||
updateTextPreviewWidgets(this, message)
|
||||
}
|
||||
}
|
||||
})
|
||||
103
src/extensions/core/textPreviewWidgets.test.ts
Normal file
103
src/extensions/core/textPreviewWidgets.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
value?: unknown
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { rootGraph: { id: 'graph-1' } } }))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
resolveNodeRootGraphId: () => 'graph-1'
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue',
|
||||
() => ({
|
||||
default: {}
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: class {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
type: string
|
||||
serialize?: boolean
|
||||
constructor(obj: {
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
type: string
|
||||
}) {
|
||||
this.name = obj.name
|
||||
this.options = obj.options
|
||||
this.type = obj.type
|
||||
}
|
||||
},
|
||||
addWidget: (node: { widgets?: MockWidget[] }, widget: MockWidget) => {
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
ComfyWidgets: {
|
||||
BOOLEAN: (node: { widgets?: MockWidget[] }, name: string) => {
|
||||
const widget: MockWidget = { name, options: {}, value: false }
|
||||
node.widgets = node.widgets ?? []
|
||||
node.widgets.push(widget)
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const { addTextPreviewWidgets, updateTextPreviewWidgets } =
|
||||
await import('./textPreviewWidgets')
|
||||
|
||||
function makeNode(): LGraphNode & { widgets: MockWidget[] } {
|
||||
return { id: '1', widgets: [] } as unknown as LGraphNode & {
|
||||
widgets: MockWidget[]
|
||||
}
|
||||
}
|
||||
|
||||
describe('addTextPreviewWidgets', () => {
|
||||
it('adds a non-serialized preview widget and a non-serialized mode toggle', () => {
|
||||
const node = makeNode()
|
||||
addTextPreviewWidgets(node)
|
||||
|
||||
const preview = node.widgets.find((w) => w.name === 'preview_text')
|
||||
const mode = node.widgets.find((w) => w.name === 'preview_mode')
|
||||
|
||||
expect(preview?.type).toBe('textPreview')
|
||||
expect(preview?.serialize).toBe(false)
|
||||
expect(preview?.options.serialize).toBe(false)
|
||||
expect(mode?.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTextPreviewWidgets', () => {
|
||||
let node: LGraphNode & { widgets: MockWidget[] }
|
||||
|
||||
beforeEach(() => {
|
||||
node = makeNode()
|
||||
node.widgets.push({ name: 'preview_text', options: {}, value: '' })
|
||||
})
|
||||
|
||||
it('joins array text into the preview widget value', () => {
|
||||
updateTextPreviewWidgets(node, { text: ['a', 'b'] })
|
||||
expect(node.widgets[0].value).toBe('a\n\nb')
|
||||
})
|
||||
|
||||
it('writes a plain string message as-is', () => {
|
||||
updateTextPreviewWidgets(node, { text: 'hello' })
|
||||
expect(node.widgets[0].value).toBe('hello')
|
||||
})
|
||||
})
|
||||
78
src/extensions/core/textPreviewWidgets.ts
Normal file
78
src/extensions/core/textPreviewWidgets.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
|
||||
import WidgetTextPreview from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextPreview.vue'
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const PREVIEW_WIDGET_NAME = 'preview_text'
|
||||
const MODE_WIDGET_NAME = 'preview_mode'
|
||||
|
||||
const inputSpecTextPreview: CustomInputSpec = {
|
||||
name: PREVIEW_WIDGET_NAME,
|
||||
type: 'TEXT_PREVIEW',
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
export function addTextPreviewWidgets(node: LGraphNode) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let fallbackValue = ''
|
||||
|
||||
const previewWidgetId = () =>
|
||||
widgetId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
PREVIEW_WIDGET_NAME
|
||||
)
|
||||
|
||||
const preview = new ComponentWidgetImpl<string | object>({
|
||||
node,
|
||||
name: PREVIEW_WIDGET_NAME,
|
||||
component: WidgetTextPreview,
|
||||
inputSpec: inputSpecTextPreview,
|
||||
type: 'textPreview',
|
||||
options: {
|
||||
serialize: false,
|
||||
hideInPanel: true,
|
||||
getMinHeight: () => 60,
|
||||
getValue: () => {
|
||||
const stored = widgetStore.getWidget(previewWidgetId())?.value
|
||||
return typeof stored === 'string' ? stored : fallbackValue
|
||||
},
|
||||
setValue: (value: string | object) => {
|
||||
fallbackValue = typeof value === 'string' ? value : ''
|
||||
const state = widgetStore.getWidget(previewWidgetId())
|
||||
if (state) state.value = fallbackValue
|
||||
}
|
||||
}
|
||||
})
|
||||
preview.serialize = false
|
||||
addWidget(node, preview)
|
||||
|
||||
const modeWidget = ComfyWidgets['BOOLEAN'](
|
||||
node,
|
||||
MODE_WIDGET_NAME,
|
||||
[
|
||||
'BOOLEAN',
|
||||
{ label_on: 'Markdown', label_off: 'Plain text', default: false }
|
||||
],
|
||||
app
|
||||
).widget
|
||||
|
||||
modeWidget.options.serialize = false
|
||||
modeWidget.serialize = false
|
||||
}
|
||||
|
||||
export function updateTextPreviewWidgets(
|
||||
node: LGraphNode,
|
||||
message: { text?: string | string[] }
|
||||
) {
|
||||
const preview = node.widgets?.find((w) => w.name === PREVIEW_WIDGET_NAME)
|
||||
if (!preview) return
|
||||
|
||||
const text = message.text ?? ''
|
||||
preview.value = Array.isArray(text) ? text.join('\n\n') : text
|
||||
}
|
||||
@@ -621,10 +621,13 @@
|
||||
"namePlaceholder": "أدخل اسمك هنا",
|
||||
"profileCreationNav": "إنشاء الملف الشخصي",
|
||||
"startPublishingButton": "ابدأ النشر",
|
||||
"startUpdatingButton": "تحديث سير العمل",
|
||||
"successDescription": "يمكنك الآن رفع سير عملك على صفحة المبدع الخاصة بك",
|
||||
"successProfileLink": "comfy.com/p/{username}",
|
||||
"successProfileUrl": "صفحتك الشخصية متاحة الآن على",
|
||||
"successTitle": "يبدو رائعًا، {'@'}{username}!",
|
||||
"updateIntroDescription": "ادفع أحدث التغييرات إلى ComfyHub. سيبقى رابط المشاركة والإحصائيات كما هي.",
|
||||
"updateIntroTitle": "تحديث سير العمل الخاص بك على ComfyHub",
|
||||
"uploadCover": "+ رفع صورة الغلاف",
|
||||
"uploadProfilePicture": "+ رفع صورة الملف الشخصي",
|
||||
"uploadWorkflowButton": "رفع سير عملي",
|
||||
@@ -648,6 +651,8 @@
|
||||
"publishSuccessDescription": "تم نشر سير العمل الخاص بك على ComfyHub.",
|
||||
"publishSuccessTitle": "تم النشر بنجاح",
|
||||
"removeExampleImage": "إزالة الصورة النموذجية",
|
||||
"renameFailedDescription": "تم نشر سير العمل بنجاح، لكن فشلت إعادة تسمية الملف المحلي. يرجى إعادة تسميته مرة أخرى ليتطابق.",
|
||||
"renameFailedTitle": "فشل إعادة التسمية",
|
||||
"selectAThumbnail": "اختر صورة مصغرة",
|
||||
"shareAs": "مشاركة كـ",
|
||||
"showLessTags": "عرض أقل...",
|
||||
@@ -665,6 +670,7 @@
|
||||
"thumbnailVideo": "فيديو",
|
||||
"title": "النشر على ComfyHub",
|
||||
"unsavedDescription": "يجب حفظ سير العمل الخاص بك قبل النشر على ComfyHub. احفظه الآن للمتابعة.",
|
||||
"updateButton": "تحديث سير العمل",
|
||||
"uploadAnImage": "انقر للاستعراض أو اسحب صورة",
|
||||
"uploadComparison": "رفع صورة قبل وبعد",
|
||||
"uploadComparisonAfterPrompt": "بعد",
|
||||
@@ -783,6 +789,8 @@
|
||||
"faqs": "الأسئلة المتكررة",
|
||||
"invoiceHistory": "تاريخ الفواتير",
|
||||
"lastUpdated": "آخر تحديث",
|
||||
"loadEventsError": "فشل في تحميل النشاط. يرجى المحاولة مرة أخرى.",
|
||||
"loadEventsUnknownError": "حدث خطأ أثناء تحميل النشاط. يرجى إعادة التحميل والمحاولة مرة أخرى.",
|
||||
"messageSupport": "مراسلة الدعم",
|
||||
"model": "النموذج",
|
||||
"purchaseCredits": "شراء رصيد",
|
||||
@@ -1564,6 +1572,7 @@
|
||||
"extensions": "الملحقات",
|
||||
"failed": "فشل",
|
||||
"failedToCopyJobId": "فشل نسخ معرف المهمة",
|
||||
"failedToDownloadFile": "فشل في تنزيل الملف",
|
||||
"failedToDownloadImage": "فشل في تنزيل الصورة",
|
||||
"failedToDownloadVideo": "فشل في تنزيل الفيديو",
|
||||
"favorites": "المفضلة",
|
||||
@@ -1808,6 +1817,7 @@
|
||||
"systemStatsRAMTotal": "إجمالي الذاكرة (RAM)",
|
||||
"systemStatsTemplatesVersion": "إصدار القوالب",
|
||||
"terminal": "الطرفية",
|
||||
"textFailedToLoad": "فشل تحميل النص",
|
||||
"title": "العنوان",
|
||||
"triggerPhrase": "عبارة التشغيل",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
@@ -2401,6 +2411,11 @@
|
||||
"pending": "قيد الانتظار",
|
||||
"unknown": "غير معروف"
|
||||
},
|
||||
"survey": {
|
||||
"error": "تعذر تحميل هذا الاستبيان. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"intro": "تثبيت العقد المخصصة قادم قريبًا إلى Comfy Cloud. أجب عن بعض الأسئلة السريعة للانضمام إلى قائمة الانتظار والمساهمة في تطوير هذه الميزة.",
|
||||
"title": "انضم إلى قائمة الانتظار"
|
||||
},
|
||||
"title": "مدير العقد المخصصة",
|
||||
"toFinishSetup": "لإكمال الإعداد",
|
||||
"totalNodes": "إجمالي العقد",
|
||||
@@ -2769,7 +2784,6 @@
|
||||
"Runway": "رن واي",
|
||||
"Sonilo": "Sonilo",
|
||||
"Sora": "سورا",
|
||||
"Stability AI": "Stability AI",
|
||||
"Tencent": "Tencent",
|
||||
"Topaz": "Topaz",
|
||||
"Tripo": "تريبو",
|
||||
@@ -2801,7 +2815,6 @@
|
||||
"cosmos": "cosmos",
|
||||
"create": "إنشاء",
|
||||
"custom": "مخصص",
|
||||
"custom_sampling": "تجميع مخصص",
|
||||
"dancer": "راقص",
|
||||
"debug": "تصحيح",
|
||||
"detection": "الكشف",
|
||||
@@ -2865,7 +2878,6 @@
|
||||
"stable video": "stable video",
|
||||
"stable video 3d": "stable video 3d",
|
||||
"stable zero123": "stable zero123",
|
||||
"stable_cascade": "سلسلة ثابتة",
|
||||
"supir": "supir",
|
||||
"text": "نص",
|
||||
"training": "تدريب",
|
||||
@@ -3102,8 +3114,11 @@
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
"errorHelpSupport": "تواصل مع الدعم الفني",
|
||||
"errorLog": "سجل الأخطاء",
|
||||
"errorNodeSummary": "{nodes} عقدة — {count} خطأ | {nodes} عقدة — {count} أخطاء",
|
||||
"errorNodesSummary": "{nodes} عقد — {count} خطأ | {nodes} عقد — {count} أخطاء",
|
||||
"errors": "الأخطاء",
|
||||
"errorsDetected": "تم اكتشاف خطأ | تم اكتشاف أخطاء",
|
||||
"errorsSummary": "{count} خطأ | {count} أخطاء",
|
||||
"executionErrorOccurred": "حدث خطأ أثناء التنفيذ. تحقق من علامة تبويب الأخطاء لمزيد من التفاصيل.",
|
||||
"expand": "توسيع",
|
||||
"fallbackGroupTitle": "مجموعة",
|
||||
@@ -3193,6 +3208,8 @@
|
||||
"resetToDefault": "إعادة التعيين إلى الافتراضي",
|
||||
"resolveBeforeRun": "يرجى الحل قبل تشغيل سير العمل",
|
||||
"seeError": "عرض الخطأ",
|
||||
"selectedNodeErrors": "{node} — {count} خطأ | {node} — {count} أخطاء",
|
||||
"selectedNodesErrors": "{nodes} عقد محددة — {count} خطأ | {nodes} عقد محددة — {count} أخطاء",
|
||||
"settings": "الإعدادات",
|
||||
"showAdvancedInputsButton": "إظهار المدخلات المتقدمة",
|
||||
"showAdvancedShort": "إظهار الخيارات المتقدمة",
|
||||
@@ -3223,6 +3240,10 @@
|
||||
"namePlaceholder": "مثال: مفتاح API الخاص بي",
|
||||
"noSecrets": "لا توجد أسرار مخزنة. أضف أول مفتاح API للبدء.",
|
||||
"provider": "المزود",
|
||||
"providerHelp": {
|
||||
"gemini": "أدخل مفتاح Google Gemini API الخاص بك. يمكنك إنشاء واحد في Google AI Studio.",
|
||||
"runway": "أدخل مفتاح Runway API الخاص بك. يمكنك العثور عليه في إعدادات حسابك على Runway ضمن مفاتيح API."
|
||||
},
|
||||
"providerHint": "اختياري. اختيار مزود يتيح استخدام الرمز تلقائيًا.",
|
||||
"secretValue": "قيمة السر",
|
||||
"secretValueHint": "سيتم تشفير هذه القيمة ولا يمكن عرضها مرة أخرى.",
|
||||
|
||||
@@ -1166,6 +1166,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedAudio": {
|
||||
"description": "أنشئ كلامًا، موسيقى، مؤثرات صوتية وحوارًا متعدد المتحدثين من خلال مطالبة واحدة باستخدام ByteDance Seed Audio 1.0. صف الصوت أو الأصوات، العاطفة، الأجواء، الموسيقى الخلفية والمؤثرات الصوتية في المطالبة، وأدرج الجمل التي سيتم نطقها. يمكنك اختيار صوت مدمج مسبقًا، أو استنساخ أصوات من حتى ٣ مقاطع مرجعية (موسومة @Audio1-3 في المطالبة)، أو اشتقاق صوت من صورة شخصية. حتى دقيقتين من الصوت لكل تشغيل.",
|
||||
"display_name": "ByteDance Seed Audio 1.0",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"loudness_rate": {
|
||||
"name": "loudness_rate",
|
||||
"tooltip": "مستوى الصوت. ٠ = عادي، ١٠٠ = ٢.٠×، -٥٠ = ٠.٥×."
|
||||
},
|
||||
"pitch_rate": {
|
||||
"name": "pitch_rate",
|
||||
"tooltip": "تغيير طبقة الصوت بالنصف نغمة (-١٢ إلى ١٢)."
|
||||
},
|
||||
"reference_mode": {
|
||||
"name": "reference_mode",
|
||||
"tooltip": "كيفية تحديد الصوت: 'نص فقط' (صف كل شيء في المطالبة)، 'مرجع صوتي' (استنساخ حتى ٣ أصوات، موسومة @Audio1-3)، 'مرجع صورة' (اشتقاق صوت من صورة شخصية واحدة)، أو 'صوت مدمج' (اختر صوتًا مدمجًا يقرأ المطالبة)."
|
||||
},
|
||||
"sample_rate": {
|
||||
"name": "sample_rate",
|
||||
"tooltip": "معدل العينة الناتج بالهرتز."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"speech_rate": {
|
||||
"name": "speech_rate",
|
||||
"tooltip": "سرعة الكلام. ٠ = عادي، ١٠٠ = ٢.٠×، -٥٠ = ٠.٥×."
|
||||
},
|
||||
"text_prompt": {
|
||||
"name": "text_prompt",
|
||||
"tooltip": "صف الصوت أو الأصوات، العاطفة، الإيقاع، الأجواء، الموسيقى الخلفية والمؤثرات الصوتية، وأدرج الجمل التي سيتم نطقها (قم بتسمية الشخصيات ضمن النص للحوار). في وضع 'المرجع الصوتي'، أشر إلى المقاطع المتصلة حسب الترتيب كـ @Audio1، @Audio2، @Audio3. الحد الأقصى ٣٠٠٠ حرف."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedNode": {
|
||||
"description": "إنشاء استجابات نصية باستخدام نماذج Seed 2.0 من ByteDance. قدم مطالبة نصية ويمكنك أيضًا إضافة صورة أو أكثر أو فيديوهات للسياق متعدد الوسائط.",
|
||||
"display_name": "ByteDance Seed",
|
||||
@@ -1266,15 +1308,9 @@
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"model_fail_on_partial": {
|
||||
"name": "فشل عند التوليد الجزئي"
|
||||
},
|
||||
"model_height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"model_max_images": {
|
||||
"name": "أقصى عدد للصور"
|
||||
},
|
||||
"model_size_preset": {
|
||||
"name": "إعداد الحجم"
|
||||
},
|
||||
@@ -2009,6 +2045,10 @@
|
||||
"1": {
|
||||
"name": "سداسي عشري",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "alpha",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2236,6 +2276,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConditioningMultiply": {
|
||||
"display_name": "التهيئة (ضرب)",
|
||||
"inputs": {
|
||||
"conditioning": {
|
||||
"name": "conditioning"
|
||||
},
|
||||
"multiplier": {
|
||||
"name": "multiplier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConditioningSetArea": {
|
||||
"display_name": "التهيئة (تعيين منطقة)",
|
||||
"inputs": {
|
||||
@@ -5196,6 +5247,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiVideoOmni": {
|
||||
"description": "أنشئ فيديو مع صوت من مطالبة نصية باستخدام نموذج Google Gemini Omni Flash. يمكنك اختيار صور و/أو مقاطع فيديو مرجعية لتوجيه أو تعديل النتيجة. صف الطول المطلوب (٣-١٠ ثوانٍ) ونسبة العرض إلى الارتفاع (١٦:٩ أو ٩:١٦) مباشرة في المطالبة.",
|
||||
"display_name": "Google Gemini Omni (فيديو)",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "نموذج Gemini للفيديو المستخدم في إنشاء الفيديو."
|
||||
},
|
||||
"model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "توليد المسارات",
|
||||
"inputs": {
|
||||
@@ -6170,94 +6255,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"IdeogramV1": {
|
||||
"description": "ينشئ صورًا تزامنيًا باستخدام نموذج Ideogram V1.\n\nروابط الصور متاحة لفترة محدودة؛ إذا أردت الاحتفاظ بالصورة، يجب تنزيلها.",
|
||||
"display_name": "Ideogram V1",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة_الأبعاد",
|
||||
"tooltip": "نسبة الأبعاد لتوليد الصورة."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"magic_prompt_option": {
|
||||
"name": "خيار_الوصف_السحري",
|
||||
"tooltip": "تحديد ما إذا كان يجب استخدام MagicPrompt في التوليد"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "الوصف_السلبي",
|
||||
"tooltip": "وصف ما يجب استبعاده من الصورة"
|
||||
},
|
||||
"num_images": {
|
||||
"name": "عدد_الصور"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف",
|
||||
"tooltip": "الوصف لتوليد الصورة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة"
|
||||
},
|
||||
"turbo": {
|
||||
"name": "الوضع_السريع",
|
||||
"tooltip": "هل تستخدم وضع التيربو (توليد أسرع، جودة أقل محتملة)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"IdeogramV2": {
|
||||
"description": "ينشئ الصور بشكل متزامن باستخدام نموذج إيديوغرام الإصدار 2.\n\nروابط الصور متاحة لفترة محدودة من الوقت؛ إذا كنت ترغب في الاحتفاظ بالصورة، يجب عليك تنزيلها.",
|
||||
"display_name": "إيديوغرام الإصدار 2",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع",
|
||||
"tooltip": "نسبة العرض إلى الارتفاع لتوليد الصورة. يتم تجاهلها إذا لم يتم تعيين الدقة إلى تلقائي."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"magic_prompt_option": {
|
||||
"name": "خيار الموجه السحري",
|
||||
"tooltip": "تحديد ما إذا كان يجب استخدام الموجه السحري في التوليد"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "الموجه السلبي",
|
||||
"tooltip": "وصف ما يجب استبعاده من الصورة"
|
||||
},
|
||||
"num_images": {
|
||||
"name": "عدد الصور"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الموجه",
|
||||
"tooltip": "الموجه لتوليد الصورة"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة",
|
||||
"tooltip": "دقة توليد الصورة. إذا لم يتم تعيينها إلى تلقائي، فإنها تتجاوز إعداد نسبة العرض إلى الارتفاع."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة"
|
||||
},
|
||||
"style_type": {
|
||||
"name": "نوع الأسلوب",
|
||||
"tooltip": "نوع الأسلوب للتوليد (الإصدار 2 فقط)"
|
||||
},
|
||||
"turbo": {
|
||||
"name": "تيربو",
|
||||
"tooltip": "هل يتم استخدام وضع التيربو (توليد أسرع، وجودة قد تكون أقل)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"IdeogramV3": {
|
||||
"description": "ينشئ الصور بشكل متزامن باستخدام نموذج إيديوغرام الإصدار 3.\n\nيدعم التوليد العادي للصور من النصوص وتحرير الصور مع القناع.\nروابط الصور متاحة لفترة محدودة من الوقت؛ إذا كنت ترغب في الاحتفاظ بالصورة، يجب عليك تنزيلها.",
|
||||
"display_name": "إيديوغرام الإصدار 3",
|
||||
@@ -14642,13 +14639,8 @@
|
||||
"PreviewAny": {
|
||||
"display_name": "معاينة أي",
|
||||
"inputs": {
|
||||
"previewMode": {},
|
||||
"preview_markdown": {
|
||||
"name": "معاينة"
|
||||
},
|
||||
"preview_text": {
|
||||
"name": "معاينة"
|
||||
},
|
||||
"preview_mode": "وضع المعاينة",
|
||||
"preview_text": {},
|
||||
"source": {
|
||||
"name": "المصدر"
|
||||
}
|
||||
@@ -18465,293 +18457,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityAudioInpaint": {
|
||||
"description": "يحول جزءًا من عينة الصوت الحالية باستخدام تعليمات نصية.",
|
||||
"display_name": "إعادة رسم الصوت من Stability AI",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "صوت",
|
||||
"tooltip": "يجب أن يكون الصوت بين 6 و190 ثانية."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة",
|
||||
"tooltip": "يتحكم في مدة الصوت المُنشأ بالثواني."
|
||||
},
|
||||
"mask_end": {
|
||||
"name": "نهاية القناع"
|
||||
},
|
||||
"mask_start": {
|
||||
"name": "بداية القناع"
|
||||
},
|
||||
"model": {
|
||||
"name": "نموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "مُوجِه"
|
||||
},
|
||||
"seed": {
|
||||
"name": "بذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة في الإنشاء."
|
||||
},
|
||||
"steps": {
|
||||
"name": "خطوات",
|
||||
"tooltip": "يتحكم في عدد خطوات أخذ العينات."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityAudioToAudio": {
|
||||
"description": "يحول عينات الصوت الحالية إلى تركيبات جديدة عالية الجودة باستخدام تعليمات نصية.",
|
||||
"display_name": "تحويل الصوت إلى صوت من Stability AI",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "صوت",
|
||||
"tooltip": "يجب أن يكون الصوت بين 6 و190 ثانية."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة",
|
||||
"tooltip": "تتحكم في مدة الصوت المُنشأ بالثواني."
|
||||
},
|
||||
"model": {
|
||||
"name": "نموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "مُوجِه"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة في الإنشاء."
|
||||
},
|
||||
"steps": {
|
||||
"name": "الخطوات",
|
||||
"tooltip": "تتحكم في عدد خطوات أخذ العينات."
|
||||
},
|
||||
"strength": {
|
||||
"name": "القوة",
|
||||
"tooltip": "تتحكم المعلمة في مقدار تأثير معامل الصوت على الصوت المُنشأ."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityStableImageSD_3_5Node": {
|
||||
"description": "ينتج الصور بشكل متزامن بناءً على النص والنسبة.",
|
||||
"display_name": "Stability AI صورة Stable Diffusion 3.5",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع",
|
||||
"tooltip": "نسبة عرض الصورة الناتجة."
|
||||
},
|
||||
"cfg_scale": {
|
||||
"name": "مقياس CFG",
|
||||
"tooltip": "مدى التزام عملية الانتشار بالنص الوصفي (القيم الأعلى تبقي الصورة أقرب للنص)."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة"
|
||||
},
|
||||
"image_denoise": {
|
||||
"name": "إزالة التشويش من الصورة",
|
||||
"tooltip": "0.0 تعني صورة مطابقة للأصل، 1.0 تعني عدم وجود صورة أصلية."
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "نص سلبي",
|
||||
"tooltip": "الكلمات التي لا ترغب برؤيتها في الصورة الناتجة. ميزة متقدمة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص الوصفي",
|
||||
"tooltip": "ما ترغب برؤيته في الصورة الناتجة. نص وصفي قوي وواضح يحدد العناصر والألوان والموضوعات يؤدي لنتائج أفضل."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية لإنشاء الضجيج."
|
||||
},
|
||||
"style_preset": {
|
||||
"name": "نمط مسبق",
|
||||
"tooltip": "النمط المرغوب اختياريًا للصورة الناتجة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityStableImageUltraNode": {
|
||||
"description": "ينتج الصور بشكل متزامن بناءً على النص والنسبة.",
|
||||
"display_name": "Stability AI صورة Stable Ultra",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع",
|
||||
"tooltip": "نسبة عرض الصورة الناتجة."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة"
|
||||
},
|
||||
"image_denoise": {
|
||||
"name": "إزالة التشويش من الصورة",
|
||||
"tooltip": "0.0 تعني صورة مطابقة للأصل، 1.0 تعني عدم وجود صورة أصلية."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "نص سلبي",
|
||||
"tooltip": "وصف لما لا ترغب برؤيته في الصورة الناتجة. ميزة متقدمة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص الوصفي",
|
||||
"tooltip": "ما ترغب برؤيته في الصورة الناتجة. نص وصفي قوي وواضح يحدد العناصر والألوان والموضوعات يؤدي لنتائج أفضل. للتحكم في وزن كلمة معينة استخدم التنسيق (الكلمة:الوزن) حيث الوزن بين 0 و1."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية لإنشاء الضجيج."
|
||||
},
|
||||
"style_preset": {
|
||||
"name": "نمط مسبق",
|
||||
"tooltip": "النمط المرغوب اختياريًا للصورة الناتجة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityTextToAudio": {
|
||||
"description": "ينشئ موسيقى ومؤثرات صوتية عالية الجودة من أوصاف نصية.",
|
||||
"display_name": "Stability AI تحويل النص إلى صوت",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة",
|
||||
"tooltip": "تتحكم في مدة الصوت المُنشأ بالثواني."
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة في الإنشاء."
|
||||
},
|
||||
"steps": {
|
||||
"name": "الخطوات",
|
||||
"tooltip": "تتحكم في عدد خطوات أخذ العينات."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityUpscaleConservativeNode": {
|
||||
"description": "يكبر الصورة مع تغييرات طفيفة إلى دقة 4K.",
|
||||
"display_name": "Stability AI تكبير محافظ",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"creativity": {
|
||||
"name": "الإبداع",
|
||||
"tooltip": "يتحكم في احتمالية إضافة تفاصيل إضافية ليست متأثرة بقوة بالصورة الأصلية."
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "نص سلبي",
|
||||
"tooltip": "الكلمات التي لا ترغب برؤيتها في الصورة الناتجة. ميزة متقدمة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص الوصفي",
|
||||
"tooltip": "ما ترغب برؤيته في الصورة الناتجة. نص وصفي قوي وواضح يحدد العناصر والألوان والموضوعات يؤدي لنتائج أفضل."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية لإنشاء الضجيج."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityUpscaleCreativeNode": {
|
||||
"description": "تكبير الصورة مع تغييرات طفيفة إلى دقة 4K.",
|
||||
"display_name": "تكبير استقرار الذكاء الاصطناعي الإبداعي",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"creativity": {
|
||||
"name": "الإبداع",
|
||||
"tooltip": "يتحكم في احتمالية إنشاء تفاصيل إضافية غير معتمدة بشكل كبير على الصورة الأصلية."
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "النص السلبي",
|
||||
"tooltip": "كلمات مفتاحية لما لا ترغب في رؤيته في الصورة الناتجة. هذه ميزة متقدمة."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص الوصفي",
|
||||
"tooltip": "ما ترغب في رؤيته في الصورة الناتجة. النص الوصفي القوي والواضح الذي يحدد العناصر والألوان والمواضيع بدقة يؤدي إلى نتائج أفضل."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة لإنشاء الضجيج."
|
||||
},
|
||||
"style_preset": {
|
||||
"name": "نمط مسبق",
|
||||
"tooltip": "النمط المرغوب اختياريًا للصورة المولدة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StabilityUpscaleFastNode": {
|
||||
"description": "يزيد حجم الصورة بسرعة عبر استدعاء API الخاص باستقرار الذكاء الاصطناعي إلى 4 أضعاف الحجم الأصلي؛ مخصص لتكبير الصور منخفضة الجودة أو المضغوطة.",
|
||||
"display_name": "تكبير استقرار الذكاء الاصطناعي السريع",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"StableCascade_EmptyLatentImage": {
|
||||
"display_name": "صورة كامنة فارغة من StableCascade",
|
||||
"inputs": {
|
||||
@@ -19759,6 +19464,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TextOverlay": {
|
||||
"description": "إضافة نص فوق صورة أو مجموعة صور.",
|
||||
"display_name": "إضافة نص على الصورة",
|
||||
"inputs": {
|
||||
"align": {
|
||||
"name": "محاذاة"
|
||||
},
|
||||
"color": {
|
||||
"name": "اللون",
|
||||
"tooltip": "لون النص."
|
||||
},
|
||||
"font_size": {
|
||||
"name": "حجم الخط",
|
||||
"tooltip": "حجم الخط كنسبة مئوية من ارتفاع الصورة."
|
||||
},
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
},
|
||||
"outline": {
|
||||
"name": "حد خارجي",
|
||||
"tooltip": "رسم حد أسود حول النص."
|
||||
},
|
||||
"position": {
|
||||
"name": "الموضع"
|
||||
},
|
||||
"text": {
|
||||
"name": "النص"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TextToLowercase": {
|
||||
"display_name": "تحويل النص إلى أحرف صغيرة",
|
||||
"inputs": {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"errorLoadingVideo": "Error loading video",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
"failedToDownloadFile": "Failed to download file",
|
||||
"failedToDownloadVideo": "Failed to download video",
|
||||
"calculatingDimensions": "Calculating dimensions",
|
||||
"import": "Import",
|
||||
@@ -58,6 +59,7 @@
|
||||
"logs": "Logs",
|
||||
"videoFailedToLoad": "Video failed to load",
|
||||
"audioFailedToLoad": "Audio failed to load",
|
||||
"textFailedToLoad": "Text failed to load",
|
||||
"liveSamplingPreview": "Live sampling preview",
|
||||
"extensionName": "Extension Name",
|
||||
"reloadToApplyChanges": "Reload to apply changes",
|
||||
@@ -1702,14 +1704,12 @@
|
||||
"Directories": "Directories"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"experimental": "experimental",
|
||||
"custom_sampling": "custom_sampling",
|
||||
"model": "model",
|
||||
"sampling": "sampling",
|
||||
"noise": "noise",
|
||||
"text": "text",
|
||||
"image": "image",
|
||||
"adjustments": "adjustments",
|
||||
"model": "model",
|
||||
"sampling": "sampling",
|
||||
"schedulers": "schedulers",
|
||||
"custom": "custom",
|
||||
"conditioning": "conditioning",
|
||||
@@ -1734,6 +1734,7 @@
|
||||
"patch": "patch",
|
||||
"chroma radiance": "chroma radiance",
|
||||
"Anthropic": "Anthropic",
|
||||
"experimental": "experimental",
|
||||
"attention_experiments": "attention_experiments",
|
||||
"flux": "flux",
|
||||
"hidream": "hidream",
|
||||
@@ -1813,8 +1814,6 @@
|
||||
"stable diffusion upscaler": "stable diffusion upscaler",
|
||||
"clip": "clip",
|
||||
"Sonilo": "Sonilo",
|
||||
"Stability AI": "Stability AI",
|
||||
"stable_cascade": "stable_cascade",
|
||||
"stable zero123": "stable zero123",
|
||||
"supir": "supir",
|
||||
"stable video 3d": "stable video 3d",
|
||||
@@ -2594,7 +2593,7 @@
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"usedAfterMonthly": "Used after plan credits run out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
@@ -2832,7 +2831,10 @@
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"enterprisePlanName": "Enterprise",
|
||||
"percentUsed": "{percent}% used",
|
||||
"usageProgress": "{used} of {total} credits used"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2847,7 +2849,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"inviteLimitReached": "Your workspace is at the member limit",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2862,12 +2864,17 @@
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
"pendingCount": "Pending ({count})",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"creditsUsed": "Credits used this month",
|
||||
"email": "Email",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2883,14 +2890,22 @@
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
|
||||
"renameWorkspace": "Rename Workspace"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2979,6 +2994,117 @@
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"memberLimitDialog": {
|
||||
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
|
||||
"title": "Workspace is at the member limit"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"workflowQueuedDialog": {
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
|
||||
"title": "Your workflow is queued"
|
||||
},
|
||||
"billingStatus": {
|
||||
"ending": {
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan",
|
||||
"title": "Your team plan ends on {date}"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"addCredits": "Add credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Out of credits"
|
||||
},
|
||||
"paused": {
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
|
||||
"title": "Subscription paused"
|
||||
},
|
||||
"updatePayment": "Update payment",
|
||||
"warning": {
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"title": "Payment declined"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"changePlan": "Change plan",
|
||||
"inactive": {
|
||||
"reactivate": "Reactivate plan",
|
||||
"subtitle": "Reactivate your team plan to add more members and run workflows",
|
||||
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
|
||||
"title": "Inactive team subscription",
|
||||
"titleEnterprise": "Inactive enterprise subscription"
|
||||
},
|
||||
"learnMore": "Learn more",
|
||||
"managePayment": "Manage payment",
|
||||
"messageSupport": "Message support",
|
||||
"paused": "Paused",
|
||||
"perMonth": "mo",
|
||||
"pricingTable": "Partner Nodes pricing table",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"seeMore": "See more",
|
||||
"snapshot": {
|
||||
"creditsUsed": "Credits used",
|
||||
"empty": {
|
||||
"recentActivity": "No activity yet",
|
||||
"topSpenders": "No credits used yet this month"
|
||||
},
|
||||
"lastActivity": "Last activity",
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
},
|
||||
"nextInvoice": "Next month invoice",
|
||||
"usd": "USD"
|
||||
},
|
||||
"invoices": {
|
||||
"fullHistory": "Full invoice history"
|
||||
},
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"invoices": "Invoices",
|
||||
"overview": "Credits"
|
||||
}
|
||||
},
|
||||
"autoReload": {
|
||||
"badge": {
|
||||
"off": "Off",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"dialog": {
|
||||
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
|
||||
"amountLabel": "Add this amount of credits:",
|
||||
"budgetPlaceholderCredits": "Enter an amount of credits",
|
||||
"budgetPlaceholderUsd": "Enter an amount of dollars",
|
||||
"budgetToggleHint": "Limit how much is auto-reloaded per month",
|
||||
"budgetToggleLabel": "Monthly budget",
|
||||
"cancel": "Cancel",
|
||||
"minReload": "Minimum amount is {amount}",
|
||||
"thresholdLabel": "When credits drop below:",
|
||||
"title": "Auto-reload credits",
|
||||
"update": "Update"
|
||||
},
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"empty": {
|
||||
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
|
||||
"cta": "Set up auto-reload"
|
||||
},
|
||||
"enabled": "Enabled",
|
||||
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
|
||||
"tile": {
|
||||
"label": "Auto-reload",
|
||||
"monthlyBudget": "Monthly budget",
|
||||
"percentSpent": "{percent}% spent",
|
||||
"spentOfBudget": "{spent} of {budget}",
|
||||
"whenBelow": "when credits drop below"
|
||||
},
|
||||
"title": "Credit auto-reload"
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
@@ -2991,7 +3117,7 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
@@ -3001,7 +3127,8 @@
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
"failedToSwitch": "Failed to switch workspace",
|
||||
"roleAdmin": "Admin"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
@@ -3123,57 +3250,52 @@
|
||||
"cloudOnboarding": {
|
||||
"skipToCloudApp": "Skip to the cloud app",
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"title": "Let's get to know you",
|
||||
"placeholder": "Survey questions placeholder",
|
||||
"intro": "Help us tailor your ComfyUI experience.",
|
||||
"intro": "A few quick questions so we can set up ComfyUI for you.",
|
||||
"otherPlaceholder": "Tell us more",
|
||||
"errors": {
|
||||
"chooseAnOption": "Please choose an option.",
|
||||
"selectAtLeastOne": "Please select at least one option.",
|
||||
"describeAnswer": "Please describe your answer."
|
||||
},
|
||||
"steps": {
|
||||
"usage": "How do you plan to use ComfyUI?",
|
||||
"familiarity": "How familiar are you with ComfyUI?",
|
||||
"intent": "What do you want to create with ComfyUI?",
|
||||
"source": "Where did you hear about ComfyUI?"
|
||||
"describeAnswer": "Please describe your answer.",
|
||||
"answerTooLong": "Please keep your answer under {max} characters."
|
||||
},
|
||||
"options": {
|
||||
"usage": {
|
||||
"personal": "Personal use",
|
||||
"work": "Work",
|
||||
"education": "Education (student or educator)"
|
||||
},
|
||||
"familiarity": {
|
||||
"new": "New — never used it",
|
||||
"starting": "Beginner — following tutorials",
|
||||
"basics": "Intermediate — comfortable with basics",
|
||||
"advanced": "Advanced — build and edit workflows",
|
||||
"expert": "Expert — I help others"
|
||||
},
|
||||
"intent": {
|
||||
"workflows": "Custom workflows or pipelines",
|
||||
"custom_nodes": "Custom nodes",
|
||||
"videos": "Videos",
|
||||
"images": "Images",
|
||||
"3d_game": "3D assets / game assets",
|
||||
"audio": "Audio / music",
|
||||
"apps": "Simplified Apps from workflows",
|
||||
"api": "API endpoints to run workflows",
|
||||
"not_sure": "Not sure"
|
||||
"video": "Video",
|
||||
"workflows": "Workflows and pipelines",
|
||||
"apps_api": "Apps and APIs",
|
||||
"exploring": "Just exploring",
|
||||
"other": "Something else",
|
||||
"otherPlaceholder": "What do you want to make?"
|
||||
},
|
||||
"experience": {
|
||||
"new": "New to ComfyUI",
|
||||
"some": "I know my way around",
|
||||
"pro": "I'm a power user"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Custom nodes",
|
||||
"pipelines": "Automated pipelines",
|
||||
"products": "Products for others"
|
||||
},
|
||||
"source": {
|
||||
"social": "Social media",
|
||||
"friend": "A friend or colleague",
|
||||
"search": "Web search",
|
||||
"community": "A community or forum",
|
||||
"other": "Somewhere else",
|
||||
"otherPlaceholder": "Where did you find us?"
|
||||
},
|
||||
"source_social": {
|
||||
"youtube": "YouTube",
|
||||
"reddit": "Reddit",
|
||||
"twitter": "Twitter / X",
|
||||
"twitter": "X (Twitter)",
|
||||
"instagram": "Instagram",
|
||||
"tiktok": "TikTok",
|
||||
"linkedin": "LinkedIn",
|
||||
"friend": "Friend or colleague",
|
||||
"search": "Google / search",
|
||||
"newsletter": "Newsletter or blog",
|
||||
"conference": "Conference or event",
|
||||
"discord": "Discord / community",
|
||||
"github": "GitHub",
|
||||
"other": "Other"
|
||||
"discord": "Discord"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3266,10 +3388,11 @@
|
||||
"cloudForgotPassword_emailRequired": "Email is required",
|
||||
"cloudForgotPassword_passwordResetSent": "Password reset sent",
|
||||
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
|
||||
"cloudSurvey_steps_usage": "How do you plan to use ComfyUI?",
|
||||
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "What do you want to create with ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Where did you hear about ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "What do you want to make?",
|
||||
"cloudSurvey_steps_experience": "How well do you know ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "What are you building?",
|
||||
"cloudSurvey_steps_source": "How did you find us?",
|
||||
"cloudSurvey_steps_source_social": "Which platform?",
|
||||
"assetBrowser": {
|
||||
"allCategory": "All {category}",
|
||||
"allModels": "All Models",
|
||||
@@ -4401,7 +4524,11 @@
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g., My API Key",
|
||||
"provider": "Provider",
|
||||
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
|
||||
"providerHint": "Select a provider to enable automatic token usage.",
|
||||
"providerHelp": {
|
||||
"runway": "Enter your Runway API key. You can find it in your Runway account settings under API keys.",
|
||||
"gemini": "Enter your Google Gemini API key. You can generate one in Google AI Studio."
|
||||
},
|
||||
"secretValue": "Secret Value",
|
||||
"secretValuePlaceholder": "Enter your API key",
|
||||
"secretValuePlaceholderEdit": "Enter new value to change",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user