Compare commits

..

11 Commits

Author SHA1 Message Date
huang47
05a984620a test: address test-quality-lens majors, minors, and a CodeRabbit finding
Stops mocking the real LGraphGroup class in useCoreCommands tests for
GroupSelectedNodes/FitGroupToContents, asserting the actual resize and
pin state instead of mock-echoes. Adds afterEach cleanup for
vi.stubGlobal('confirm'/'open'), a console.error spy, and the RAF/CAF
stubs in useSelectionToolboxPosition (also closes a CodeRabbit thread).
Swaps as-Response casts for real Response instances in usePainter,
reuses the shared createMockCanvas2DContext factory instead of a
hand-rolled one, and strengthens a bare not.toThrow in useBrushDrawing.
2026-07-07 19:35:46 -07:00
huang47
60c683e090 test: use fromPartial for missing-id positionable fixture 2026-07-06 15:05:46 -07:00
huang47
0007744169 test: strengthen core command tests to assert differentiating behavior
Replace delegation echoes flagged as change detectors: cover the mask
editor undo/redo branch, assert Mute/Bypass mode arguments, interrupt
job-id routing, model store refresh, and theme toggle direction/memory.
2026-07-06 11:58:54 -07:00
huang47
ea5238242c test: address review feedback on composable mocks 2026-07-03 21:21:50 -07:00
huang47
c8aa2d569c test: deduplicate canvas mock construction in selection toolbox tests 2026-07-03 14:54:41 -07:00
huang47
54bf7496f8 test: fix lint violations and typecheck errors in composable mock refactor
Replace as-unknown-as double casts with fromPartial<T>, fromAny imports with
fromPartial, and vi.mock('vue-i18n') with real createI18n instances across
six test files. Fix constructor-inference issue in LGraphNode partial via
Object.assign, add HTMLImageElement cast for EventTarget targets with
naturalWidth, tighten makeWidget param type, and restructure LGraphGroup mock
to expose per-call methods via shared vi.fn() refs to avoid unchecked casts.
2026-07-03 13:39:33 -07:00
huang47
51ccc2d581 test: verify drawing state reset after missing-context error 2026-07-02 18:26:43 -07:00
huang47
3926c60ac4 test: strengthen null-context painter assertion and reset root graph nodes between tests 2026-07-02 18:16:51 -07:00
huang47
fae7f005db test: address second-round review findings
Assert toolbox visibility alongside cleared coordinates, fix
tautological dead-zone assertion, exercise the real no-blob branch in
painter serialization, stub toBlob in the canvas helper, restore the
shared ds mock after mutation.
2026-07-02 18:06:11 -07:00
huang47
aaa861694e test: address review findings on composable mock refactor
Derive brush-drawing MockStore from the real store type, restore
console/performance spies via afterEach, make dominant-axis suppression
and size-clamp tests assert the real behavior, exercise the unmount
guard in the painter restore test, dedupe FakeImage and error-path
setup, split combined rename negative scenarios, use fromPartial for
partial DOM mocks.
2026-07-02 17:36:52 -07:00
huang47
e30d6cfbbc test: rework composable test mocks to hoisted mutable fixtures
Split out of #13364: these files needed their existing vi.mock
scaffolding rewritten (static literals -> hoisted mutable mocks,
some whole describe blocks replaced) to support the new coverage,
rather than just appending tests. Isolated here so #13364 stays
purely additive.
2026-07-02 14:35:25 -07:00
160 changed files with 4065 additions and 7209 deletions

View File

@@ -1,30 +0,0 @@
---
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
globs:
- src/components/LiteGraphCanvasSplitterOverlay.vue
- src/platform/agent/**
alwaysApply: true
---
# Agent Panel Layout
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
## Correct structure
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
```
div.flex-row (viewport)
├── div.flex-col.flex-1 ← left side: everything else
│ ├── slot#workflow-tabs ← header bar
│ └── div.flex-1 ← canvas + sidebar panels
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
```
## Rules
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.

View File

@@ -1,34 +0,0 @@
---
description: Icon buttons must always have a tooltip
globs: src/**/*.vue
alwaysApply: false
---
# Icon Button Tooltip Requirement
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
## Required Pattern
```vue
<Tooltip>
<TooltipTrigger>
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
<i class="icon-[lucide--some-icon] size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
</Tooltip>
```
## Imports
```ts
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
```
- Always use `side="top"` unless a different direction is needed for layout reasons.
- The `aria-label` on the button and the tooltip text should be the same translated string.
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -1,120 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -1,108 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -29,7 +27,6 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -44,17 +41,14 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false,
class: className
videoHideControls = false
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -78,8 +72,7 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
)
"
>
@@ -91,7 +84,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -100,13 +93,6 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -141,29 +127,27 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</div>
</section>
</template>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -7,14 +7,12 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section',
align = 'center'
headingSize = 'section'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -30,14 +28,7 @@ const headingSizeClass = {
</script>
<template>
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,8 +37,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
]
},
{

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// 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.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ 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"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -19,8 +19,7 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models',
mcp: '/mcp'
models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
@@ -66,8 +65,6 @@ export const externalLinks = {
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',
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/',

View File

@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
}
},
{

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,19 +69,10 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -189,6 +180,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -11,16 +11,6 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -1835,311 +1825,6 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'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。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -2182,7 +1867,6 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -2913,18 +2597,18 @@ const translations = {
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.',
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
'zh-CN':
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
},
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',

View File

@@ -1,24 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -1,24 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,45 +162,6 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}

View File

@@ -1,195 +0,0 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -1,64 +0,0 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -1,27 +0,0 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* 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.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,4 +1,3 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -7,370 +6,72 @@ import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
import { assetPath } from '@e2e/fixtures/utils/paths'
const file1 = 'workflow.mp4' as const
const file2 = 'video-preview-wide.webm' as const
const file3 = 'video-preview-square.webm' as const
const file4 = 'video-preview-portrait.webm' as const
const MIN_PREVIEW_FRAME_HEIGHT = 100
const CENTER_TOLERANCE_PX = 1
const videoShapeFixtures = [
[file2, 'landscape'],
[file3, 'square'],
[file4, 'portrait']
] as const
const file2 = 'workflow.webm' as const
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
interface VideoPreviewLayout {
objectFit: string
objectPosition: string
wrapperHeight: number
wrapperWidth: number
wrapperX: number
wrapperY: number
videoBoxHeight: number
videoBoxWidth: number
videoIntrinsicHeight: number
videoIntrinsicWidth: number
videoX: number
videoY: number
}
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
async function readVideoPreviewLayout(
preview: Locator
): Promise<VideoPreviewLayout | null> {
return await preview.evaluate((previewElement) => {
const video = previewElement.querySelector('video')
const wrapper = video?.parentElement
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
const wrapperRect = wrapper.getBoundingClientRect()
const videoRect = video.getBoundingClientRect()
return {
objectFit: getComputedStyle(video).objectFit,
objectPosition: getComputedStyle(video).objectPosition,
wrapperHeight: wrapperRect.height,
wrapperWidth: wrapperRect.width,
wrapperX: wrapperRect.x,
wrapperY: wrapperRect.y,
videoBoxHeight: videoRect.height,
videoBoxWidth: videoRect.width,
videoIntrinsicHeight: video.videoHeight,
videoIntrinsicWidth: video.videoWidth,
videoX: videoRect.x,
videoY: videoRect.y
}
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})
}
async function requireBoundingBox(locator: Locator, subject: string) {
const box = await locator.boundingBox()
if (!box) throw new Error(`${subject} should have a bounding box`)
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
})
return box
}
await test.step('Update displayed video', async () => {
const initialSrc = await loadVideo.videoSrc()
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
await expect(loadVideoNode).toContainText(file2)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
})
async function expectNodeBoxUnchanged(
locator: Locator,
before: { height: number; width: number },
subject: string
) {
const after = await requireBoundingBox(locator, subject)
expect(
Math.abs(after.width - before.width),
`${subject} should not change node width`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(after.height - before.height),
`${subject} should not change node height`
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
}
await test.step('Display multiple videmus', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
function objectPositionFraction(value: string) {
if (value.endsWith('%')) return Number.parseFloat(value) / 100
switch (value) {
case 'left':
case 'top':
return 0
case 'center':
return 0.5
case 'right':
case 'bottom':
return 1
default:
throw new Error(`Unsupported object-position value: ${value}`)
}
}
function objectPositionFractions(objectPosition: string) {
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
return {
x: objectPositionFraction(x),
y: objectPositionFraction(y)
}
}
function getPaintedVideoRect({
objectPosition,
videoBoxHeight,
videoBoxWidth,
videoIntrinsicHeight,
videoIntrinsicWidth,
videoX,
videoY
}: VideoPreviewLayout) {
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
const boxAspectRatio = videoBoxWidth / videoBoxHeight
const paintedWidth =
videoAspectRatio > boxAspectRatio
? videoBoxWidth
: videoBoxHeight * videoAspectRatio
const paintedHeight =
videoAspectRatio > boxAspectRatio
? videoBoxWidth / videoAspectRatio
: videoBoxHeight
const position = objectPositionFractions(objectPosition)
return {
height: paintedHeight,
width: paintedWidth,
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
y: videoY + (videoBoxHeight - paintedHeight) * position.y
}
}
function expectAspectRatioMatchesShape(
aspectRatio: number,
shape: ThumbnailShape
) {
if (shape === 'landscape') {
expect(
aspectRatio,
'landscape fixture should be wider than tall'
).toBeGreaterThan(1)
return
}
if (shape === 'portrait') {
expect(
aspectRatio,
'portrait fixture should be taller than wide'
).toBeLessThan(1)
return
}
expect(
Math.abs(aspectRatio - 1),
'square fixture should have matching width and height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
}
async function expectCenteredVideoPreview(preview: Locator) {
await expect
.poll(async () => {
const layout = await readVideoPreviewLayout(preview)
return layout?.videoIntrinsicWidth ?? 0
})
.toBeGreaterThan(0)
const layout = await readVideoPreviewLayout(preview)
if (!layout) throw new Error('Video preview should render a video element')
expect(
layout.wrapperHeight,
'video preview should keep a usable minimum frame height'
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeGreaterThan(0)
expect(layout.videoBoxHeight).toBeGreaterThan(0)
expect(layout.objectFit).toBe('contain')
const objectPosition = objectPositionFractions(layout.objectPosition)
expect(objectPosition.x).toBe(0.5)
expect(objectPosition.y).toBe(0.5)
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
const paintedVideo = getPaintedVideoRect(layout)
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
expect(
Math.abs(paintedVideoCenterX - wrapperCenterX),
'painted video should be horizontally centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(
Math.abs(paintedVideoCenterY - wrapperCenterY),
'painted video should be vertically centered in the preview space'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
expect(paintedVideo.width).toBeLessThanOrEqual(
layout.wrapperWidth + CENTER_TOLERANCE_PX
)
expect(paintedVideo.height).toBeLessThanOrEqual(
layout.wrapperHeight + CENTER_TOLERANCE_PX
)
return layout
}
test.describe(
'VideoPreview',
{ tag: ['@vue-nodes', '@node', '@widget'] },
() => {
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
const loadVideo = new VideoPreview(loadVideoNode)
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Video')
await expect(loadVideoNode).toHaveCount(1)
await expect(loadVideoNode).toBeVisible()
})
const loadVideoFixture =
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
await test.step('Upload a video file', async () => {
await loadVideo.upload.setInputFiles(
assetPath(`workflowInMedia/${file1}`)
)
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
await expect(loadVideoNode).toContainText(file1)
await expect(loadVideo.video).toBeVisible()
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
})
await test.step('Update displayed video across thumbnail shapes', async () => {
for (const [filename, shape] of videoShapeFixtures) {
const initialSrc = await loadVideo.videoSrc()
const nodeBoxBeforeLoad = await requireBoundingBox(
loadVideoNode,
`Load Video node before loading ${filename}`
)
await loadVideo.upload.setInputFiles(assetPath(`video/${filename}`))
comfyFiles.deleteAfterTest({
//forcibly display multiple video files at once
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
type: 'input'
})
await expect(loadVideoNode).toContainText(filename)
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
await expectNodeBoxUnchanged(
loadVideoNode,
nodeBoxBeforeLoad,
`Load Video node after loading ${filename}`
)
const updatedVideoAspectRatio =
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
}
})
await test.step('Keep video centered after horizontal resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before horizontal resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
subfolder: ''
}))
)
},
[file1, file2]
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollWidth)
.toBeGreaterThan(nodeBox.width + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperWidth - initialLayout.wrapperWidth,
'video preview space should grow with a wider node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
'horizontal resize should not change the preview space height'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).click()
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
await test.step('Keep video centered after vertical resize', async () => {
const nodeBox = await requireBoundingBox(
loadVideoNode,
'Load Video node before vertical resize'
)
const initialLayout = await expectCenteredVideoPreview(
loadVideo.preview
)
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
await comfyPage.nextFrame()
await expect
.poll(loadVideoFixture.pollHeight)
.toBeGreaterThan(nodeBox.height + 100)
const layout = await expectCenteredVideoPreview(loadVideo.preview)
expect(
layout.wrapperHeight - initialLayout.wrapperHeight,
'video preview space should grow with a taller node'
).toBeGreaterThan(100)
expect(
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
'vertical resize should not change the preview space width'
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
})
await test.step('Display multiple videos', async () => {
await expect(loadVideo.navigationDots).toBeHidden()
try {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(
(names) => {
graph!.nodes[0].images.splice(
0,
1,
...names.map((filename) => ({
type: 'input',
filename,
subfolder: ''
}))
)
},
[file1, file2]
)
} finally {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.nextFrame()
}
await expect(loadVideo.navigationDots).toHaveCount(2)
await loadVideo.navigationDots.nth(0).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
await loadVideo.navigationDots.nth(1).press('Enter')
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
})
await test.step('Can redownload uploaded file', async () => {
await loadVideo.video.hover()
await expect(loadVideo.download).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})
}
)
const downloadPromise = comfyPage.page.waitForEvent('download')
await loadVideo.download.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(file2)
})
})

1
global.d.ts vendored
View File

@@ -5,7 +5,6 @@ declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
declare const __GIT_BRANCH_PREFIX__: string
interface ImpactQueueFunction {
(...args: unknown[]): void

View File

@@ -120,7 +120,6 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"shiki": "catalog:",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",

77
pnpm-lock.yaml generated
View File

@@ -321,9 +321,6 @@ catalogs:
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
shiki:
specifier: ^3.0.0
version: 3.23.0
storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -609,9 +606,6 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
shiki:
specifier: 'catalog:'
version: 3.23.0
three:
specifier: 'catalog:'
version: 0.184.0
@@ -3445,30 +3439,18 @@ packages:
pinia:
optional: true
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/core@4.1.0':
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/engine-javascript@4.1.0':
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/engine-oniguruma@4.1.0':
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
engines: {node: '>=20'}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/langs@4.1.0':
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
engines: {node: '>=20'}
@@ -3477,16 +3459,10 @@ packages:
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
engines: {node: '>=20'}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/themes@4.1.0':
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
engines: {node: '>=20'}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/types@4.1.0':
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
engines: {node: '>=20'}
@@ -7799,9 +7775,6 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
shiki@4.1.0:
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
engines: {node: '>=20'}
@@ -8759,8 +8732,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.6:
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -11353,13 +11326,6 @@ snapshots:
optionalDependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@4.1.0':
dependencies:
'@shikijs/primitive': 4.1.0
@@ -11368,32 +11334,17 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-javascript@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/langs@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
@@ -11404,19 +11355,10 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/themes@4.1.0':
dependencies:
'@shikijs/types': 4.1.0
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@4.1.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
@@ -11524,7 +11466,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.6
vue-component-type-helpers: 3.3.5
'@swc/helpers@0.5.21':
dependencies:
@@ -16565,17 +16507,6 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shiki@4.1.0:
dependencies:
'@shikijs/core': 4.1.0
@@ -17706,7 +17637,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.6: {}
vue-component-type-helpers@3.3.5: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -116,7 +116,6 @@ catalog:
primevue: ^4.2.5
reka-ui: 2.5.0
rollup-plugin-visualizer: ^6.0.4
shiki: ^3.0.0
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -1,226 +1,5 @@
@import '@comfyorg/design-system/css/style.css';
/* Markdown prose styles for the agent chat, matching Figma DES-455 tokens */
.agent-markdown h1,
.agent-markdown h2,
.agent-markdown p,
.agent-markdown ol,
.agent-markdown ul,
.agent-markdown li,
.agent-markdown table {
margin: 0;
}
.agent-markdown h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: normal;
padding-top: 1rem;
padding-bottom: 0.5rem;
}
.agent-markdown h2 {
font-size: 1rem;
font-weight: 600;
padding-top: 0.875rem;
padding-bottom: 0.375rem;
}
.agent-markdown p {
font-size: 0.875rem;
padding-bottom: 0;
}
.agent-markdown p:has(> em:only-child) {
padding-top: 1.25rem;
}
.agent-markdown ol,
.agent-markdown ul {
font-size: 0.875rem;
padding-left: 1.25rem;
padding-bottom: 0.5rem;
}
.agent-markdown ol {
list-style-type: decimal;
}
.agent-markdown ul {
list-style-type: disc;
}
.agent-markdown strong {
font-weight: 600;
}
.agent-markdown a {
color: var(--color-primary-background);
text-decoration: underline;
cursor: pointer;
}
.agent-markdown blockquote {
margin: 0.5rem 0;
padding: 0.375rem 0.875rem;
border-left: 3px solid var(--color-border-default);
color: var(--color-muted-foreground);
font-size: 0.875rem;
}
.agent-markdown table {
width: 100%;
font-size: 0.875rem;
margin-bottom: 0.5rem;
border-collapse: collapse;
border-radius: 0.5rem;
overflow: hidden;
background-color: var(--color-secondary-background);
}
.agent-markdown th {
font-weight: 600;
text-align: left;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-border-default);
background-color: var(--color-secondary-background-hover);
}
.agent-markdown td {
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-border-default);
}
.agent-markdown tr:last-child td {
border-bottom: none;
}
.agent-markdown > *:first-child {
padding-top: 0;
}
.agent-markdown > *:last-child {
padding-bottom: 0;
margin-bottom: 0;
}
/* Scroll-driven fade mask for conversation containers.
Top edge fades in as you scroll away from the start;
bottom edge fades out when you reach the end. */
@property --sf-top {
syntax: '<length>';
inherits: false;
initial-value: 0;
}
@property --sf-bottom {
syntax: '<length>';
inherits: false;
initial-value: 40px;
}
@keyframes sf-grow-top {
from { --sf-top: 0; }
to { --sf-top: 40px; }
}
@keyframes sf-shrink-bottom {
from { --sf-bottom: 40px; }
to { --sf-bottom: 0; }
}
.scroll-fade {
mask-image: linear-gradient(
to bottom,
transparent 0,
black var(--sf-top),
black calc(100% - var(--sf-bottom)),
transparent 100%
);
animation: sf-grow-top linear both, sf-shrink-bottom linear both;
animation-timeline: scroll(self y), scroll(self y);
animation-range: 0 80px, calc(100% - 80px) 100%;
}
.agent-code-block {
border: 1px solid var(--color-border-default);
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.5rem;
}
.agent-code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.75rem;
background-color: var(--color-secondary-background-hover);
border-bottom: 1px solid var(--color-border-default);
}
.agent-code-block-label {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-muted-foreground);
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.agent-code-block-filename {
color: var(--color-base-foreground);
font-size: 0.6875rem;
font-weight: 500;
font-family: inherit;
}
.agent-code-block-copy {
background: transparent;
border: 1px solid var(--color-border-default);
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
font-size: 0.6875rem;
font-family: inherit;
padding: 0.125rem 0.5rem;
line-height: 1.5;
transition: background-color 0.15s, color 0.15s;
}
.agent-code-block-copy:hover {
background-color: var(--color-secondary-background);
color: var(--color-base-foreground);
}
.agent-code-block pre {
margin: 0;
padding: 0.75rem;
overflow-x: auto;
}
.agent-code-block code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.6875rem;
line-height: 1.6;
color: var(--color-base-foreground);
white-space: pre;
}
.agent-inline-code {
background-color: var(--color-secondary-background-hover);
border: 1px solid var(--color-border-default);
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
padding: 0.125rem 0.375rem;
}
@keyframes shimmer-sweep {
from { background-position: 100% center; }
to { background-position: 0% center; }
}
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,

View File

@@ -1,154 +1,133 @@
<template>
<div
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
>
<!-- Left column: workflow tabs + canvas/panels -->
<div class="pointer-events-none flex flex-1 flex-col overflow-hidden">
<slot name="workflow-tabs" />
<slot name="workflow-tabs" />
<div
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel
class="graph-canvas-panel relative overflow-visible"
>
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
</div>
</div>
<!-- Right column: agent panel, full viewport height -->
<div
v-if="agentPanelVisible"
class="pointer-events-auto relative h-full shrink-0 overflow-hidden border-l border-interface-stroke bg-comfy-menu-bg"
:style="{ width: `${agentPanelWidth}px` }"
class="pointer-events-none flex flex-1 overflow-hidden"
:class="{
'flex-row': sidebarLocation === 'left',
'flex-row-reverse': sidebarLocation === 'right'
}"
>
<div
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
:data-resizing="isResizing"
@pointerdown="onResizePointerDown"
@lostpointercapture="isResizing = false"
/>
<slot name="agent-panel" />
<div class="side-toolbar-container">
<slot name="side-toolbar" />
</div>
<Splitter
:key="splitterRefreshKey"
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
:state-key="
isSelectMode
? sidebarLocation === 'left'
? 'builder-splitter'
: 'builder-splitter-right'
: sidebarStateKey
"
state-storage="local"
@resizestart="onResizestart"
@resizeend="normalizeSavedSizes"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="firstPanelVisible"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
layout="vertical"
:pt:gutter="
cn(
'rounded-t-lg',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"
state-key="bottom-panel-splitter"
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
>
<slot name="bottom-panel" />
</SplitterPanel>
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="lastPanelVisible"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
</Splitter>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Splitter from 'primevue/splitter'
import type { SplitterResizeStartEvent } from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
@@ -158,7 +137,6 @@ import {
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -169,26 +147,6 @@ const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const agentPanelStore = useAgentPanelStore()
const { isOpen: agentPanelVisible, width: agentPanelWidth } =
storeToRefs(agentPanelStore)
const isResizing = ref(false)
let resizeStartX = 0
let resizeStartWidth = 0
function onResizePointerDown(e: PointerEvent) {
isResizing.value = true
resizeStartX = e.clientX
resizeStartWidth = agentPanelStore.width
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
e.preventDefault()
}
useEventListener(document, 'pointermove', (e: PointerEvent) => {
if (!isResizing.value) return
agentPanelStore.setWidth(resizeStartWidth + (resizeStartX - e.clientX))
})
const { t } = useI18n()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -346,14 +304,4 @@ const lastPanelStyle = computed(() => {
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
transform: translateY(5px);
}
.agent-resize-handle:hover {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
.agent-resize-handle[data-resizing='true'] {
transition: none;
background-color: var(--p-primary-color);
}
</style>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, provide } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import CodeBlockContainer from './CodeBlockContainer.vue'
import CodeBlockContent from './CodeBlockContent.vue'
import { CodeBlockKey } from './context'
const {
code,
language,
showLineNumbers = false,
class: className
} = defineProps<{
code: string
language: string
showLineNumbers?: boolean
class?: HTMLAttributes['class']
}>()
provide(CodeBlockKey, { code: computed(() => code) })
</script>
<template>
<CodeBlockContainer :class="cn('text-xs', className)" :language="language">
<slot />
<CodeBlockContent
:code="code"
:language="language"
:show-line-numbers="showLineNumbers"
/>
</CodeBlockContainer>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center gap-1', className)">
<slot />
</div>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, language } = defineProps<{
class?: HTMLAttributes['class']
language: string
}>()
const containerStyle = {
contentVisibility: 'auto' as const,
containIntrinsicSize: 'auto 200px'
}
</script>
<template>
<div
:class="
cn(
'group relative w-full overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground',
className
)
"
:data-language="language"
:style="containerStyle"
>
<slot />
</div>
</template>

View File

@@ -1,105 +0,0 @@
<script setup lang="ts">
import type { BundledLanguage, ThemedToken } from 'shiki'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref, watch } from 'vue'
import type { TokenizedCode } from './utils'
import {
createRawTokens,
highlightCode,
isBold,
isItalic,
isUnderline
} from './utils'
const {
code,
language,
showLineNumbers = false
} = defineProps<{
code: string
language: string
showLineNumbers?: boolean
}>()
const rawTokens = computed(() => createRawTokens(code))
const tokenized = ref<TokenizedCode>(
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
)
watch(
() => [code, language],
() => {
tokenized.value =
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
highlightCode(code, language as BundledLanguage, (result) => {
tokenized.value = result
})
},
{ immediate: true }
)
const preStyle = computed(() => ({
color: tokenized.value.fg
}))
interface KeyedToken {
token: ThemedToken
key: string
}
interface KeyedLine {
tokens: KeyedToken[]
key: string
}
const keyedLines = computed<KeyedLine[]>(() =>
tokenized.value.tokens.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
token,
key: `line-${lineIdx}-${tokenIdx}`
}))
}))
)
const lineNumberClasses = cn(
'block',
'before:content-[counter(line)]',
'before:inline-block',
'before:[counter-increment:line]',
'before:w-8',
'before:mr-4',
'before:text-right',
'before:text-muted-foreground/50',
'before:font-mono',
'before:select-none'
)
</script>
<template>
<div class="relative overflow-auto">
<pre
class="m-0 overflow-auto bg-base-background p-4 text-sm"
:style="preStyle"
><code
:class="
cn(
'font-mono text-sm',
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
)
"
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><span
v-for="tokenObj in line.tokens"
:key="tokenObj.key"
:style="{
color: tokenObj.token.color,
backgroundColor: tokenObj.token.bgColor,
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
textDecoration: isUnderline(tokenObj.token.fontStyle)
? 'underline'
: undefined,
}"
>{{ tokenObj.token.content }}</span></span></template></code></pre>
</div>
</template>

View File

@@ -1,74 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, inject, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
import { CodeBlockKey } from './context'
const { timeout = 2000, class: className } = defineProps<{
timeout?: number
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
copy: []
error: [error: Error]
}>()
const { t } = useI18n()
const context = inject(CodeBlockKey)
if (!context)
throw new Error('CodeBlockCopyButton must be used within a CodeBlock')
const { code } = context
const isCopied = ref(false)
let resetTimer: ReturnType<typeof setTimeout> | undefined
const label = computed(() => (isCopied.value ? t('g.copied') : t('g.copy')))
async function copyToClipboard() {
if (!navigator?.clipboard?.writeText) {
emit('error', new Error('Clipboard API not available'))
return
}
try {
await navigator.clipboard.writeText(code.value)
isCopied.value = true
emit('copy')
clearTimeout(resetTimer)
resetTimer = setTimeout(() => {
isCopied.value = false
}, timeout)
} catch (error) {
emit('error', error instanceof Error ? error : new Error('Copy failed'))
}
}
onBeforeUnmount(() => clearTimeout(resetTimer))
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button
:class="cn('shrink-0', className)"
size="icon-sm"
variant="muted-textonly"
:aria-label="label"
@click="copyToClipboard"
>
<i
:class="isCopied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'"
class="size-3.5"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ label }}</TooltipContent>
</Tooltip>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
:class="cn('font-mono text-xs font-medium text-base-foreground', className)"
>
<slot />
</span>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex items-center justify-between border-b border-border-default bg-secondary-background-hover px-3 py-1.5 text-muted-foreground',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center gap-1.5', className)">
<slot />
</div>
</template>

View File

@@ -1,7 +0,0 @@
import type { ComputedRef, InjectionKey } from 'vue'
export interface CodeBlockContext {
code: ComputedRef<string>
}
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')

View File

@@ -1,91 +0,0 @@
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken
} from 'shiki'
import { createHighlighter } from 'shiki'
export const isItalic = (fontStyle: number | undefined): boolean =>
!!(fontStyle && fontStyle & 1)
export const isBold = (fontStyle: number | undefined): boolean =>
!!(fontStyle && fontStyle & 2)
export const isUnderline = (fontStyle: number | undefined): boolean =>
!!(fontStyle && fontStyle & 4)
export interface TokenizedCode {
tokens: ThemedToken[][]
fg: string
bg: string
}
const THEME: BundledTheme = 'one-dark-pro'
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>()
const tokensCache = new Map<string, TokenizedCode>()
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
function cacheKey(code: string, language: BundledLanguage): string {
const start = code.slice(0, 100)
const end = code.length > 100 ? code.slice(-100) : ''
return `${language}:${code.length}:${start}:${end}`
}
function getHighlighter(
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
const cached = highlighterCache.get(language)
if (cached) return cached
const promise = createHighlighter({ themes: [THEME], langs: [language] })
highlighterCache.set(language, promise)
return promise
}
export function createRawTokens(code: string): TokenizedCode {
return {
tokens: code
.split('\n')
.map((line) =>
line === '' ? [] : [{ content: line, color: 'inherit' } as ThemedToken]
),
fg: 'inherit',
bg: 'transparent'
}
}
export function highlightCode(
code: string,
language: BundledLanguage,
callback?: (result: TokenizedCode) => void
): TokenizedCode | null {
const key = cacheKey(code, language)
const cached = tokensCache.get(key)
if (cached) return cached
if (callback) {
if (!subscribers.has(key)) subscribers.set(key, new Set())
subscribers.get(key)!.add(callback)
}
getHighlighter(language)
.then((highlighter) => {
const loadedLangs = highlighter.getLoadedLanguages()
const lang = loadedLangs.includes(language) ? language : 'text'
const result = highlighter.codeToTokens(code, { lang, theme: THEME })
const tokenized: TokenizedCode = {
tokens: result.tokens,
fg: result.fg ?? 'inherit',
bg: result.bg ?? 'transparent'
}
tokensCache.set(key, tokenized)
subscribers.get(key)?.forEach((sub) => sub(tokenized))
subscribers.delete(key)
})
.catch(() => subscribers.delete(key))
return null
}

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useMutationObserver } from '@vueuse/core'
import type { HTMLAttributes } from 'vue'
import { provide, ref, useTemplateRef } from 'vue'
import { conversationKey } from './context'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl')
const isAtBottom = ref(true)
function updateAtBottom() {
const el = scrollEl.value
if (!el) return
isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}
function scrollToBottom() {
const el = scrollEl.value
if (!el) return
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}
useMutationObserver(
scrollEl,
() => {
if (isAtBottom.value) {
requestAnimationFrame(scrollToBottom)
}
},
{ childList: true, subtree: true, characterData: true }
)
provide(conversationKey, { isAtBottom, scrollToBottom })
</script>
<template>
<div class="relative flex-1 overflow-hidden">
<div
ref="scrollEl"
:class="cn('scroll-fade absolute inset-0 scrollbar-custom', className)"
@scroll="updateAtBottom"
>
<slot />
</div>
<slot name="overlay" />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-4 p-4', className)">
<slot />
</div>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
import { useConversation } from './context'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const { t } = useI18n()
const { isAtBottom, scrollToBottom } = useConversation()
const label = t('agent.scrollToBottom')
</script>
<template>
<div
v-if="!isAtBottom"
class="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
:class="
cn(
'pointer-events-auto rounded-full shadow-md ring-1 ring-muted-foreground',
className
)
"
:aria-label="label"
@click="scrollToBottom"
>
<i class="icon-[lucide--chevron-down] size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ label }}</TooltipContent>
</Tooltip>
</div>
</template>

View File

@@ -1,18 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import { inject } from 'vue'
export interface ConversationContext {
isAtBottom: Ref<boolean>
scrollToBottom: () => void
}
export const conversationKey: InjectionKey<ConversationContext> =
Symbol('conversation')
export function useConversation(): ConversationContext {
const context = inject(conversationKey)
if (!context) {
throw new Error('Conversation parts must be used within <Conversation>')
}
return context
}

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { from, class: className } = defineProps<{
from: 'user' | 'assistant' | 'system'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'group flex w-full gap-2',
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
const { tooltip, pressed = false } = defineProps<{
tooltip: string
pressed?: boolean
}>()
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<Tooltip :delay-duration="500">
<TooltipTrigger>
<button
type="button"
:aria-label="tooltip"
:aria-pressed="pressed"
:class="
pressed
? 'text-base-foreground'
: 'text-muted-foreground hover:text-base-foreground'
"
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-1 transition-colors hover:bg-secondary-background-hover"
@click="emit('click')"
>
<slot />
</button>
</TooltipTrigger>
<TooltipContent side="top" class="whitespace-nowrap">{{
tooltip
}}</TooltipContent>
</Tooltip>
</template>

View File

@@ -1,5 +0,0 @@
<template>
<div class="flex items-center justify-end gap-0.5">
<slot />
</div>
</template>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import type { MessageAttachment } from '@/platform/agent/composables/useAgentChatPrototype'
const { attachments } = defineProps<{
attachments: readonly MessageAttachment[]
}>()
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="flex flex-col gap-1.5">
<div
v-for="(attachment, i) in attachments"
:key="i"
class="flex items-center gap-3 rounded-lg border border-border-default bg-secondary-background p-2"
>
<div
class="size-10 shrink-0 overflow-hidden rounded-md border border-border-default"
>
<img
v-if="attachment.type.startsWith('image/')"
:src="attachment.url"
:alt="attachment.name"
class="size-full object-cover"
/>
<div
v-else
class="flex size-full items-center justify-center bg-secondary-background-hover"
>
<i class="icon-[lucide--file] size-4 text-muted-foreground" />
</div>
</div>
<div class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium text-base-foreground">
{{ attachment.name }}
</span>
<span class="block text-xs text-muted-foreground">
{{ formatFileSize(attachment.size) }}
</span>
</div>
</div>
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex w-full flex-col gap-2 overflow-hidden text-xs text-base-foreground',
'group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary-background group-[.is-user]:px-4 group-[.is-user]:py-3',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import Response from '@/components/ai-elements/response/Response.vue'
const { content, class: className } = defineProps<{
content?: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Response :content="content" :class="className">
<slot />
</Response>
</template>

View File

@@ -1,10 +0,0 @@
<script setup lang="ts">
import Shimmer from '@/components/ai-elements/shimmer/Shimmer.vue'
</script>
<template>
<div class="flex items-center gap-1.5 text-sm">
<i class="icon-[lucide--brain] size-3.5 text-muted-foreground" />
<Shimmer>{{ $t('agent.thinking') }}</Shimmer>
</div>
</template>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ref, watch } from 'vue'
import type { ToolCall } from '@/platform/agent/composables/useAgentChatPrototype'
const { toolCalls, complete = false } = defineProps<{
toolCalls: readonly ToolCall[]
complete?: boolean
}>()
const expanded = ref(!complete)
const shouldAnimate = ref(!complete)
const totalDurationMs = toolCalls.reduce((sum, c) => sum + c.durationMs, 0)
watch(
() => complete,
(done) => {
if (done)
setTimeout(() => {
expanded.value = false
shouldAnimate.value = false
}, 1200)
}
)
function formatDuration(ms: number) {
return `${(ms / 1000).toFixed(1)}s`
}
</script>
<template>
<div class="flex flex-col">
<button
type="button"
class="flex h-8 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary-background-hover hover:text-base-foreground"
@click="expanded = !expanded"
>
<i class="icon-[lucide--wrench] size-4 shrink-0" />
<span class="flex-1">
{{
$t('agent.toolCalls.summary', {
count: toolCalls.length,
duration: formatDuration(totalDurationMs)
})
}}
</span>
<i
:class="
expanded ? 'icon-[lucide--chevron-up]' : 'icon-[lucide--chevron-down]'
"
class="size-4 shrink-0"
/>
</button>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ul v-if="expanded" class="flex list-none flex-col pl-0">
<li
v-for="(call, i) in toolCalls"
:key="i"
:class="
cn(
'relative pl-6',
shouldAnimate &&
'animate-in fade-in-0 fill-mode-both slide-in-from-top-1'
)
"
:style="
shouldAnimate
? { animationDelay: `${i * 80}ms`, animationDuration: '200ms' }
: {}
"
>
<div class="absolute inset-y-0 left-4 w-px bg-border-default" />
<div class="flex h-8 items-center gap-2 rounded-md px-2">
<i
:class="
call.status === 'success'
? 'icon-[lucide--circle-check] text-muted-foreground'
: 'icon-[lucide--circle-x] text-muted-foreground/50'
"
class="size-4 shrink-0"
/>
<span class="flex-1 truncate text-sm text-muted-foreground">{{
call.name
}}</span>
<span class="text-sm text-muted-foreground/60 tabular-nums">{{
formatDuration(call.durationMs)
}}</span>
</div>
</li>
</ul>
</Transition>
</div>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import { provide, ref } from 'vue'
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
submit: [event: Event]
}>()
const isFocused = ref(false)
provide(PROMPT_INPUT_FOCUSED_KEY, isFocused)
function onSubmit(event: Event) {
event.preventDefault()
emit('submit', event)
}
</script>
<template>
<form :class="cn('w-full', className)" @submit="onSubmit">
<slot />
</form>
</template>

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
const { attachments } = defineProps<{
attachments: File[]
}>()
const emit = defineEmits<{
remove: [index: number]
}>()
const { t } = useI18n()
const objectUrls = ref<string[]>([])
watch(
() => attachments,
(files) => {
objectUrls.value.forEach(URL.revokeObjectURL)
objectUrls.value = files.map((f) =>
f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
)
},
{ immediate: true }
)
onUnmounted(() => {
objectUrls.value.forEach(URL.revokeObjectURL)
})
function fileTypeIcon(file: File): string {
if (file.type.startsWith('audio/')) return 'icon-[lucide--music]'
if (file.type.startsWith('video/')) return 'icon-[lucide--video]'
if (file.type === 'application/pdf') return 'icon-[lucide--file-text]'
if (file.type.startsWith('text/')) return 'icon-[lucide--file-text]'
return 'icon-[lucide--paperclip]'
}
</script>
<template>
<div v-if="attachments.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
<div
v-for="(file, i) in attachments"
:key="i"
:class="
cn(
'flex h-8 items-center gap-1.5 rounded-md border border-border-default select-none',
'bg-secondary-background px-1.5 text-sm font-medium transition-colors'
)
"
>
<div class="size-5 shrink-0 overflow-hidden rounded-sm">
<img
v-if="file.type.startsWith('image/')"
:src="objectUrls[i]"
:alt="file.name"
class="size-full object-cover"
/>
<div
v-else
class="flex size-full items-center justify-center bg-secondary-background-hover"
>
<i :class="fileTypeIcon(file)" class="size-3 text-muted-foreground" />
</div>
</div>
<span class="max-w-36 truncate text-xs text-base-foreground">{{
file.name
}}</span>
<Tooltip>
<TooltipTrigger as-child>
<Button
size="icon-sm"
variant="muted-textonly"
class="size-4 shrink-0"
:aria-label="t('g.remove')"
@click="emit('remove', i)"
>
<i class="icon-[lucide--x] size-2.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('g.remove') }}</TooltipContent>
</Tooltip>
</div>
</div>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import { inject } from 'vue'
import type { PromptInputFocusedContext } from './promptInputContext'
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const isFocused = inject<PromptInputFocusedContext>(PROMPT_INPUT_FOCUSED_KEY)
function onFocusIn() {
if (isFocused) isFocused.value = true
}
function onFocusOut(e: FocusEvent) {
const current = e.currentTarget as HTMLElement | null
if (isFocused && !current?.contains(e.relatedTarget as Node)) {
isFocused.value = false
}
}
</script>
<template>
<div
:class="
cn(
'flex flex-col rounded-2xl border bg-secondary-background transition-colors',
isFocused ? 'border-muted-foreground' : 'border-border-default',
className
)
"
@focusin="onFocusIn"
@focusout="onFocusOut"
>
<slot />
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
const {
class: className,
variant = 'muted-textonly',
size = 'icon'
} = defineProps<{
class?: HTMLAttributes['class']
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
}>()
</script>
<template>
<Button type="button" :variant="variant" :size="size" :class="className">
<slot />
</Button>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import Button from '@/components/ui/button/Button.vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const model = defineModel<string>({ default: 'Auto' })
</script>
<template>
<Button type="button" variant="muted-textonly" size="sm" :class="className">
{{ model }}
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
</template>

View File

@@ -1,53 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import type { ChatStatus } from './types'
const {
class: className,
status = 'ready',
variant = 'inverted',
size = 'icon',
disabled = false
} = defineProps<{
class?: HTMLAttributes['class']
status?: ChatStatus
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
disabled?: boolean
}>()
const iconClass = computed(() => {
switch (status) {
case 'submitted':
return 'icon-[lucide--loader-circle] size-4 animate-spin'
case 'streaming':
return 'icon-[lucide--square] size-4'
case 'error':
return 'icon-[lucide--x] size-4'
default:
return 'icon-[lucide--arrow-up] size-4'
}
})
</script>
<template>
<Button
type="submit"
:variant="variant"
:size="size"
:disabled="disabled"
:class="cn('rounded-xl', className)"
:aria-label="$t('agent.send')"
>
<slot>
<i :class="iconClass" />
</slot>
</Button>
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import { ref } from 'vue'
const { class: className, placeholder } = defineProps<{
class?: HTMLAttributes['class']
placeholder?: string
}>()
const model = defineModel<string>({ default: '' })
const isComposing = ref(false)
const textareaEl = ref<HTMLTextAreaElement | null>(null)
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || isComposing.value) return
event.preventDefault()
const form = (event.target as HTMLElement).closest('form')
form?.requestSubmit()
}
function focus() {
const el = textareaEl.value
if (!el) return
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
}
defineExpose({ focus })
</script>
<template>
<textarea
ref="textareaEl"
v-model="model"
rows="1"
:placeholder="placeholder"
:class="
cn(
'field-sizing-content max-h-48 min-h-20 w-full resize-none border-none bg-transparent px-4 py-3 font-[inherit] text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none',
className
)
"
@keydown="onKeydown"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
</template>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('flex items-center justify-between gap-1 px-3 py-2', className)"
>
<slot />
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center gap-1', className)">
<slot />
</div>
</template>

View File

@@ -1,4 +0,0 @@
import type { Ref } from 'vue'
export const PROMPT_INPUT_FOCUSED_KEY = Symbol('promptInputFocused')
export type PromptInputFocusedContext = Ref<boolean>

View File

@@ -1 +0,0 @@
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'

View File

@@ -1,151 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import CodeBlock from '../code-block/CodeBlock.vue'
import CodeBlockActions from '../code-block/CodeBlockActions.vue'
import CodeBlockCopyButton from '../code-block/CodeBlockCopyButton.vue'
import CodeBlockFilename from '../code-block/CodeBlockFilename.vue'
import CodeBlockHeader from '../code-block/CodeBlockHeader.vue'
import CodeBlockTitle from '../code-block/CodeBlockTitle.vue'
const { content, class: className } = defineProps<{
content: string
class?: HTMLAttributes['class']
}>()
// Matches complete fenced code blocks: ```lang\n...content...\n```
const FENCE_RE = /^```([^\n]*)\n([\s\S]*?)^```[ \t]*$/gm
// Matches an opening fence with no closing fence — used to detect mid-stream blocks.
// Captures: [1] newline-or-start before the fence, [2] language info, [3] code content so far.
const OPEN_FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*)$/
interface HtmlSegment {
type: 'html'
key: string
html: string
}
interface CodeSegment {
type: 'code'
key: string
code: string
language: string
filename: string
}
type Segment = HtmlSegment | CodeSegment
function parseCodeInfo(info: string): { language: string; filename: string } {
const colonIdx = info.indexOf(':')
return {
language: colonIdx >= 0 ? info.slice(0, colonIdx) : info,
filename: colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
}
}
const segments = computed<Segment[]>(() => {
if (!content) return []
const result: Segment[] = []
let lastIdx = 0
let keyIdx = 0
for (const match of content.matchAll(FENCE_RE)) {
const before = content.slice(lastIdx, match.index)
if (before) {
result.push({
type: 'html',
key: `h${keyIdx++}`,
html: renderMarkdownToHtml(before)
})
}
const { language, filename } = parseCodeInfo(match[1].trim())
result.push({
type: 'code',
key: `c${keyIdx++}`,
code: match[2].replace(/\n$/, ''),
language,
filename
})
lastIdx = match.index! + match[0].length
}
const tail = content.slice(lastIdx)
const openMatch = tail.match(OPEN_FENCE_RE)
if (openMatch) {
const fenceStart = openMatch.index! + openMatch[1].length
const before = tail.slice(0, fenceStart)
if (before) {
result.push({
type: 'html',
key: `h${keyIdx++}`,
html: renderMarkdownToHtml(before)
})
}
const { language, filename } = parseCodeInfo(openMatch[2].trim())
result.push({
type: 'code',
key: `c${keyIdx}`,
code: openMatch[3],
language,
filename
})
} else if (tail) {
result.push({
type: 'html',
key: `h${keyIdx}`,
html: renderMarkdownToHtml(tail)
})
}
return result
})
</script>
<template>
<div :class="cn('agent-markdown', className)">
<template v-for="segment in segments" :key="segment.key">
<div
v-if="segment.type === 'html'"
class="contents"
v-html="segment.html"
/>
<CodeBlock
v-else
class="mb-2"
:code="segment.code"
:language="segment.language"
>
<CodeBlockHeader>
<CodeBlockTitle>
<i
:class="
segment.filename
? 'icon-[lucide--file-code]'
: 'icon-[lucide--code-2]'
"
class="size-3.5 shrink-0"
/>
<CodeBlockFilename v-if="segment.filename">
{{ segment.filename }}
</CodeBlockFilename>
<span v-else class="font-mono text-xs">
{{ segment.language || 'plaintext' }}
</span>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
</template>
</div>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import { computed, useSlots } from 'vue'
import MarkdownRenderer from './MarkdownRenderer.vue'
const { content, class: className } = defineProps<{
content?: string
class?: HTMLAttributes['class']
}>()
const slots = useSlots()
const markdown = computed(() => {
if (content !== undefined) return content
const nodes = slots.default?.() ?? []
return nodes
.map((node) => (typeof node.children === 'string' ? node.children : ''))
.join('')
})
</script>
<template>
<MarkdownRenderer
:content="markdown"
:class="cn('text-xs/relaxed', className)"
/>
</template>

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
const {
as = 'span',
duration = 2,
spread = 2,
class: className
} = defineProps<{
as?: keyof HTMLElementTagNameMap
class?: HTMLAttributes['class']
duration?: number
spread?: number
}>()
</script>
<template>
<component
:is="as"
:class="['shimmer', className]"
:style="{
'--shimmer-duration': `${duration}s`,
'--shimmer-spread': `${(($slots.default?.()[0]?.children as string)?.length ?? 10) * spread}px`
}"
>
<slot />
</component>
</template>
<style scoped>
.shimmer {
background-image:
linear-gradient(
90deg,
transparent calc(50% - var(--shimmer-spread)),
var(--color-base-foreground),
transparent calc(50% + var(--shimmer-spread))
),
linear-gradient(
var(--color-muted-foreground),
var(--color-muted-foreground)
);
background-size:
250% 100%,
auto;
background-repeat: no-repeat;
background-clip: text;
color: transparent;
animation: shimmer-sweep var(--shimmer-duration) linear infinite;
}
</style>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { suggestion, class: className } = defineProps<{
suggestion: string
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
select: [suggestion: string]
}>()
</script>
<template>
<button
type="button"
:class="
cn(
'text-foreground flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full border-0 bg-secondary-background px-3 text-sm whitespace-nowrap transition-colors outline-none hover:bg-secondary-background-hover @[460px]:w-auto',
className
)
"
@click="emit('select', suggestion)"
>
<slot />
</button>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex w-full flex-wrap justify-start gap-2 @[460px]:justify-center',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -70,6 +70,8 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />

View File

@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
}
</script>
<template>
<DropdownMenu>
<DropdownMenu button-class="icon-[lucide--list-filter]">
<template #button>
<Button size="icon" :aria-label="$t('g.filter')">
<i class="icon-[lucide--list-filter]" />
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
>
<span
class="flex-1"
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
/>
<DropdownMenuItemIndicator class="size-4 shrink-0">
<i class="icon-[lucide--check]" />

View File

@@ -50,15 +50,6 @@
class="pointer-events-auto"
/>
</template>
<template #agent-panel>
<div class="size-full p-2">
<div
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
>
<AgentChatPanel />
</div>
</div>
</template>
</LiteGraphCanvasSplitterOverlay>
<canvas
id="graph-canvas"
@@ -150,7 +141,6 @@ import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import AgentChatPanel from '@/platform/agent/components/AgentChatPanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'

View File

@@ -117,8 +117,8 @@
</template>
<script setup lang="ts">
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { mapValues } from 'es-toolkit'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
import {

View File

@@ -84,22 +84,6 @@
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<button
type="button"
class="no-drag flex h-6 shrink-0 cursor-pointer items-center gap-2 rounded-sm border px-2 text-xs text-base-foreground transition-colors"
:class="
cn(
isAgentPanelOpen
? 'border-plum-500 bg-plum-600/20'
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
)
"
:aria-label="$t('agent.ask')"
@click="agentPanelStore.toggle()"
>
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
{{ $t('agent.ask') }}
</button>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
@@ -109,7 +93,7 @@
:aria-label="$t('actionbar.feedback')"
@click="openFeedback"
>
<i class="icon-[lucide--megaphone]" />
<i class="icon-[lucide--message-square-text]" />
</Button>
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
<LoginButton
@@ -122,9 +106,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
@@ -142,7 +124,6 @@ import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
@@ -164,8 +145,6 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const agentPanelStore = useAgentPanelStore()
const { isOpen: isAgentPanelOpen } = storeToRefs(agentPanelStore)
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="empty"
:class="
cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="empty-description"
:class="cn('text-sm text-muted-foreground', className)"
>
<slot />
</p>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="empty-header"
:class="
cn('flex max-w-sm flex-col items-center gap-2 text-center', className)
"
>
<slot />
</div>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { variant = 'default', class: className } = defineProps<{
variant?: 'default' | 'icon'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="empty-media"
:data-variant="variant"
:class="
cn(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
variant === 'icon' &&
'text-foreground size-10 rounded-lg bg-muted [&_svg:not([class*=\'size-\'])]:size-6',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="empty-title"
:class="cn('text-lg font-medium tracking-tight', className)"
>
<slot />
</div>
</template>

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import { onUnmounted, provide, ref } from 'vue'
import type { TooltipContext } from './tooltipContext'
import { TOOLTIP_KEY } from './tooltipContext'
const { delayDuration = 300 } = defineProps<{
delayDuration?: number
}>()
const open = ref(false)
const triggerEl = ref<HTMLElement | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
function scheduleOpen() {
timer = setTimeout(() => {
open.value = true
}, delayDuration)
}
function close() {
if (timer) {
clearTimeout(timer)
timer = null
}
open.value = false
}
onUnmounted(close)
provide<TooltipContext>(TOOLTIP_KEY, {
open,
triggerEl,
delayDuration,
scheduleOpen,
close
})
</script>
<template>
<slot />
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { CSSProperties, HTMLAttributes } from 'vue'
import { inject, ref, watch } from 'vue'
import { TOOLTIP_KEY } from './tooltipContext'
const { class: className, side = 'bottom' } = defineProps<{
class?: HTMLAttributes['class']
side?: 'top' | 'bottom' | 'left' | 'right'
}>()
const ctx = inject(TOOLTIP_KEY)
const style = ref<CSSProperties>({})
function computeStyle() {
if (!ctx?.triggerEl.value) return {}
const rect = ctx.triggerEl.value.getBoundingClientRect()
const gap = 6
if (side === 'top') {
return {
left: `${rect.left + rect.width / 2}px`,
top: `${rect.top - gap}px`,
transform: 'translate(-50%, -100%)'
}
}
if (side === 'left') {
return {
left: `${rect.left - gap}px`,
top: `${rect.top + rect.height / 2}px`,
transform: 'translate(-100%, -50%)'
}
}
if (side === 'right') {
return {
left: `${rect.right + gap}px`,
top: `${rect.top + rect.height / 2}px`,
transform: 'translateY(-50%)'
}
}
return {
left: `${rect.left + rect.width / 2}px`,
top: `${rect.bottom + gap}px`,
transform: 'translateX(-50%)'
}
}
watch(
() => ctx?.open.value,
(open) => {
if (open) style.value = computeStyle()
}
)
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-100"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-75"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="ctx?.open.value"
:style="style"
:class="
cn(
'pointer-events-none fixed z-9999 max-w-xs rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2 py-1 text-xs leading-none text-node-component-tooltip',
className
)
"
>
<slot />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import { inject, onMounted, onUnmounted, ref } from 'vue'
import { TOOLTIP_KEY } from './tooltipContext'
const ctx = inject(TOOLTIP_KEY)
const el = ref<HTMLElement | null>(null)
function onMouseEnter() {
ctx?.scheduleOpen()
}
function onMouseLeave() {
ctx?.close()
}
function onFocus() {
ctx?.scheduleOpen()
}
function onBlur() {
ctx?.close()
}
onMounted(() => {
if (!el.value || !ctx) return
// display:contents removes the wrapper's box; use the real child for positioning
ctx.triggerEl.value =
(el.value.firstElementChild as HTMLElement | null) ?? el.value
el.value.addEventListener('mouseenter', onMouseEnter)
el.value.addEventListener('mouseleave', onMouseLeave)
el.value.addEventListener('focus', onFocus)
el.value.addEventListener('blur', onBlur)
})
onUnmounted(() => {
if (!el.value) return
el.value.removeEventListener('mouseenter', onMouseEnter)
el.value.removeEventListener('mouseleave', onMouseLeave)
el.value.removeEventListener('focus', onFocus)
el.value.removeEventListener('blur', onBlur)
})
</script>
<template>
<div ref="el" class="contents">
<slot />
</div>
</template>

View File

@@ -1,11 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
export interface TooltipContext {
open: Ref<boolean>
triggerEl: Ref<HTMLElement | null>
delayDuration: number
scheduleOpen: () => void
close: () => void
}
export const TOOLTIP_KEY: InjectionKey<TooltipContext> = Symbol('tooltip')

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