Compare commits

...

20 Commits

Author SHA1 Message Date
Alexander Brown
5da0ed119b Merge branch 'main' into feat/models-page 2026-06-09 11:17:45 -07:00
Steven Tran
8972d27689 refactor(telemetry): route execution events to GTM only (MAR-282) (#12717)
## Summary
Client-side execution events (`execution_start` / `execution_success` /
`execution_error`) are now emitted only by the GTM provider, removing
the redundant Mixpanel and PostHog emissions that duplicated the
server-side PostHog execution pipeline.

## Changes
- **Removed** `trackWorkflowExecution`, `trackExecutionError`, and
`trackExecutionSuccess` from `MixpanelTelemetryProvider` and
`PostHogTelemetryProvider`, along with the now-unused
`lastTriggerSource` field and related type imports.
- **Kept** these methods on `GtmTelemetryProvider`. The
`TelemetryProvider` interface declares them optional and
`TelemetryRegistry` dispatches via optional chaining, so callers are
unchanged and Mixpanel/PostHog simply receive nothing for these events.
- **Added** GTM unit tests for `execution_start` and `execution_success`
(alongside the existing `execution_error` test) to pin the remaining
client-side path.

## Review Focus
- Execution telemetry on the client now flows exclusively to GTM;
PostHog execution data is expected to come solely from the server side,
so there should be no double-counting.
- The server-side PostHog execution pipeline is out of scope for this
frontend change — this PR only stops the client from emitting duplicate
execution events.

Reference: MAR-282
Prior context: Comfy-Org PR #3423.

Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-09 17:26:34 +00:00
jaeone94
72d1261983 [bugfix] Use Desktop2 bridge for missing model downloads (#12710)
## Summary

Fixes the Desktop2 missing-model download path so the frontend calls the
Desktop2 download bridge directly when it is available, instead of
relying on the browser `<a download>` fallback that Desktop2 currently
has to intercept indirectly.

This addresses Linear FE-956, where missing-model downloads on Windows
could open the OS Save As dialog. The issue was reproducible when the
frontend language was not English: switching the UI language back to
English made the download succeed again.

## Root Cause

Desktop2 currently has compatibility logic that watches/intercepts the
frontend missing-model download flow from outside the FE code. That
interception depends on FE-rendered DOM details, including localized
accessible labels such as the missing-model download button
`aria-label`.

In English, Desktop2 could find the expected download controls and cache
the missing-model metadata before the FE-created `<a>` download was
clicked. In non-English locales, the localized label no longer matched
Desktop2's selector, so the Desktop2 interception path missed the
download. The FE then continued down the browser download path, which
Electron surfaced as a native Save As dialog on Windows.

## Changes

- Adds a narrow Desktop2 runtime bridge check in
`missingModelDownload.ts`:
  - if `window.__comfyDesktop2.downloadModel` exists
  - and `window.__comfyDesktop2Remote` is not set
- then FE calls `window.__comfyDesktop2.downloadModel(model.url,
model.name, model.directory)` directly and returns early.
- Keeps remote Desktop2 sessions on the existing browser fallback path
by preserving the `__comfyDesktop2Remote` guard.
- Leaves the existing OSS browser fallback and legacy desktop
`isDesktop` download-store path intact.
- Logs Desktop2 bridge failures so rejected promises or synchronous
bridge throws do not become unhandled errors.
- Adds regression coverage for:
- Desktop2 bridge path taking priority over browser and legacy desktop
fallbacks.
- rejected Desktop2 bridge calls being logged without falling back to
browser download.
- synchronously thrown Desktop2 bridge failures being logged without
crashing or falling back to browser download.
  - remote Desktop2 sessions continuing to use browser fallback.

## User Impact

Desktop2 users should no longer depend on localized FE DOM text for
missing-model downloads. In particular, non-English UI locales should
route missing-model downloads through Desktop2's managed downloader
instead of opening the OS Save As dialog.

## Validation

- Manually verified the issue is fixed in Desktop2 using a locally built
FE dist served through ComfyUI with `--front-end-root`.
- Verified Korean locale no longer triggers the Save As dialog and the
missing-model download succeeds through Desktop2.
- Verified the new regression test fails when the production bridge fix
is reverted.
- Covered the FE-side contract with unit tests because a true end-to-end
assertion of the Windows native Save As dialog is not currently
practical in the FE browser-test infrastructure. The FE tests can verify
that clicking missing-model download routes into
`window.__comfyDesktop2.downloadModel`; they cannot directly prove
Electron/Windows native dialog behavior. That full native-dialog
regression belongs in Desktop2/Electron integration coverage.
- Ran:
- `pnpm exec oxfmt --check
src/platform/missingModel/missingModelDownload.ts
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm lint:unstaged`
- `pnpm exec vitest run
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm typecheck`
  - `pnpm build`
- Pre-commit hook passed: `oxfmt`, `oxlint`, `eslint`, `typecheck`.
- Pre-push hook passed: `knip --cache` completed with existing tag hints
only.
- Ran a 3-round local Claude review loop; final verdict was approve with
no Blocker/Major findings.

## Follow-up Work

- Define and document the FE/Desktop2 bridge contract explicitly,
including the expected semantics of `downloadModel` resolving `false`
versus rejecting.
- Add a shared or canonical TypeScript declaration for
`window.__comfyDesktop2` and `window.__comfyDesktop2Remote` if more FE
code starts depending on these globals.
- Remove Desktop2's DOM/aria/class-based missing-model download
interception after a sufficient FE compatibility window, so Desktop2 no
longer depends on FE DOM structure or localized labels.
- Add Desktop2 integration/e2e coverage for missing-model downloads in
non-English locales, ideally including Windows where the Save As dialog
was observed. This is the right layer for a true native Save As
regression test.
- Optionally add a lighter FE browser E2E that injects a fake
`window.__comfyDesktop2.downloadModel` and verifies the missing-model UI
calls that bridge. This would validate the FE contract, but it would
still not replace Desktop2/Electron coverage for native dialog behavior.
- Decide on user-facing failure UX for Desktop2 bridge download failures
once Desktop2 defines whether failures, cancellations, and
already-queued downloads are represented by rejection or by `false`.

## Notes

This intentionally does not fall back to browser download when the
Desktop2 bridge resolves `false`. Falling back there could reintroduce
the exact Save As dialog behavior this PR fixes, and the meaning of
`false` should be clarified in the Desktop2 bridge contract before FE
invents user-facing behavior for it.

A true E2E test for this bug would need to exercise Desktop2/Electron on
Windows and assert that the native Save As dialog is not opened. The
current FE browser-test infrastructure cannot observe that native
Desktop2 behavior directly, so this PR uses focused unit regression
coverage for the FE routing contract plus manual Desktop2 verification.
2026-06-09 16:42:19 +00:00
jaeone94
1b90696459 Simplify missing media error presentation (#12705)
## Summary

This is the second PR in the planned Workflow Overview error-tab
modernization stack. It follows the merged execution-error slice in
#12683 and focuses only on Missing Media / Missing Inputs.

The intent is to simplify missing-media errors from
media-type/file-resolution cards into compact node-level guidance.
Missing media is now presented as a flat list of affected node inputs,
matching the new catalog-driven item-row direction introduced by the
first PR while intentionally avoiding the larger missing-node,
swap-node, and missing-model redesigns.

This PR also removes the upload/library selection workflow from the
error tab. Missing media rows now help the user locate the affected
node/input directly, rather than attempting to resolve the missing file
inside the error card.

## Changes

- **What**: Simplifies the Missing Inputs card into a flat list of
affected node/widget rows.
- **What**: Displays each missing media row with a catalog-resolved item
label in the form `{nodeName} - {inputName}`.
- **What**: Makes both the row label and right-aligned locate icon
navigate to the affected node on canvas.
- **What**: Adds item-specific accessible labels for locate icon buttons
and an explicit focus-visible state for clickable row labels.
- **What**: Uses the existing `list-scale` transition pattern so missing
media rows animate consistently when they enter or leave.
- **What**: Removes the old image/video/audio sub-sections from the
missing-media card.
- **What**: Removes missing-media
upload/dropzone/library-selection/status/confirm/cancel UI, because
resolution is no longer handled inside this card.
- **What**: Removes stale E2E selectors and tests tied to the deleted
upload/library controls.
- **What**: Removes stale English missing-media locale keys for the
deleted controls; translated locale files are intentionally left to the
localization sync process.
- **What**: Removes stale missing-media store interaction state
(`expandState`, `uploadState`, `pendingSelection`) and the now-unused
name-based removal API that only supported the deleted upload/library
flow.
- **What**: Keeps the remaining widget/node/prefix candidate-removal
APIs used by the missing-media lifecycle.
- **What**: Carries `nodeType` through missing-media referencing nodes
so fallback labels remain correct when the same missing filename is
referenced by multiple node types.
- **Breaking**: None expected. This changes the missing-media error-tab
UX, but does not change the workflow schema or backend APIs.
- **Dependencies**: None.

## Planned stack

This PR is part of the same staged redesign plan described in #12683.
The goal is to keep each error category reviewable and stable before
moving to the next slice.

1. Execution errors, merged in #12683: validation, runtime, and prompt
error grouping/presentation.
2. Missing media, this PR: simplify image/video/audio missing-media
cards into node-level catalog item rows with locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

This PR intentionally does not change missing node packs, swap nodes,
missing models, runtime errors, validation errors, or prompt errors.

## Review Focus

- Missing media rows should now be node-level rows, not file-level rows
and not image/video/audio sections.
- If the same missing media filename is referenced by multiple nodes,
each affected node/input should render as its own row.
- Clicking either the row label or locate icon should focus the
corresponding affected node.
- Removed upload/library controls are intentional and should not be
restored in this PR.
- The missing-media store cleanup should not remove any state or API
still used by the remaining missing-media lifecycle.
- Only `rightSidePanel.missingMedia.missingMediaTitle` should remain in
the English locale for the old missing-media UI namespace; new row copy
lives in the error catalog.
- This PR should stay scoped to Missing Media / Missing Inputs and
should not include the larger missing model/node/swap redesign work.

## Screenshots (if applicable)

This PR
<img width="483" height="339" alt="스크린샷 2026-06-08 오후 8 26 43"
src="https://github.com/user-attachments/assets/e029411e-b1db-41cd-aa6e-2bd5d86762f6"
/>

Before (Main)
<img width="484" height="802" alt="스크린샷 2026-06-08 오후 8 32 19"
src="https://github.com/user-attachments/assets/c183c18d-babe-4f4e-b4e0-706ce41298de"
/>

## Validation

- `pnpm format`
- `pnpm test:unit src/platform/missingMedia/missingMediaStore.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts
src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts`
- `pnpm typecheck`
- `pnpm knip --cache`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts
--project=chromium`
- Pre-push hook: `pnpm knip --cache`

## Review notes

Claude review was run after the main implementation and again after
cleanup. No blocker or major issues remained. The only final minor
finding was stale inline i18n fixture data in `TabErrors.test.ts`, which
was removed in the final commit.
2026-06-09 06:52:35 +00:00
Michael B
e2b5f533f1 fix(website): localize models hero title connector
Hardcoded English "in" in the H1 broke the zh-CN render. Repurpose the
unused models.list.heroTitle.before key and add a matching .after so the
connector lands on the correct side of the highlighted ComfyUI span per
locale.
2026-06-08 13:12:06 -04:00
Michael B
c607b98130 fix(website): omit aria-label on hero video when no label provided
Drop the empty-string default on videoAriaLabel; bind aria-label only
when truthy and set aria-hidden="true" otherwise so the video is treated
as decorative instead of carrying an empty accessible name.
2026-06-05 16:09:30 -04:00
Michael B
9e9d08febf fix(website): tighten vertical spacing on models and AI models sections 2026-06-05 16:09:30 -04:00
Michael B
411de899a5 feat(website): rename grok-image to grok-imagine with 301 redirect
Updates API_PROVIDER_MAP and adds a LEGACY_SLUG_REDIRECTS mechanism so
old supported-models URLs 301 to their canonical slug via the existing
canonicalSlug field.
2026-06-05 16:09:30 -04:00
Michael B
4db6d81804 feat(website): allow per-item object-fit/position on GalleryCard
Adds optional objectFit and objectPosition fields to GalleryItem so callers can override how each media tile is fit/anchored within its frame. Anchors Amber Passage to the bottom so the fox stays in view rather than getting cropped at the top of the cover region.
2026-06-05 16:09:30 -04:00
Michael B
58ad02db4e feat(website): replace models hero image with autoplay video
Swap the static gallery.webp image in ModelsHeroSection for a muted,
looping, playsinline video so the hero better reflects the generative
nature of the supported models. Rename mediaSrc/mediaAlt to
videoSrc/videoAriaLabel and update both en and zh-CN models pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 16:05:11 -04:00
Michael B
e0c9c96052 MAke the CTA the same size as the other CTA on the page 2026-06-05 16:05:11 -04:00
Michael B
0b2c250c85 adjust models page spacing and unify AI models CTA label
The changes:
  - Tighten top padding on mobile for ModelsHeroSection and ModelCreationsSection (pt-16 lg:pt-36)
  - Add mobile bottom padding to the gallery card stack
  - Use the mobile CTA label at all breakpoints in AIModelsSection (desktop variant commented out)
2026-06-05 16:05:11 -04:00
Michael B
676226e3ad fix(website): translate models hero alt text to Chinese on zh-CN page
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 16:05:11 -04:00
Michael B
b2176e441a chore: remove unused models showcase components
ModelsShowcaseSection and ShowcaseCard were superseded by AIModelsSection
on the models page and are no longer referenced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 16:05:10 -04:00
Michael B
f15ef81f03 Trigger re-deploy 2026-06-05 16:05:10 -04:00
Michael B
a98d75dbee feat: use shared AIModelsSection on models page
Swap ModelsShowcaseSection for the shared product/shared/AIModelsSection
on both the EN and zh-CN models pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 16:05:10 -04:00
Michael B
36c7fbfee0 refactor: extract ShowcaseCard component from models showcase
Move per-card markup out of ModelsShowcaseSection into a dedicated
ShowcaseCard component (mirroring GalleryCard). Switch the card to a
uniform h-80 height, animate the badge color on hover, and drop the
now-conflicting aspect-ratio classes that caused cards to overflow
their grid columns.
2026-06-05 16:05:10 -04:00
Michael B
3417fbb7e5 feat: add featured AI models showcase to models page 2026-06-05 16:05:10 -04:00
Michael B
6ed81e05db feat: add creations and product showcase sections to models page 2026-06-05 16:05:10 -04:00
Michael B
08354d74a3 feat: add models page hero section
Adds ModelsHeroSection component, models list pages (en and zh-CN),
and accompanying i18n translations.
2026-06-05 16:05:10 -04:00
37 changed files with 2434 additions and 1629 deletions

View File

@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Image', slug: 'grok-image' },
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
@@ -86,6 +86,20 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
// Stub entries that exist only to issue 301 redirects from old slugs to
// their new canonical slugs. Keeps renames reproducible across regenerations.
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
{
slug: 'grok-image',
canonicalSlug: 'grok-imagine',
name: 'Grok Image',
displayName: 'Grok Image',
directory: 'partner_nodes',
huggingFaceUrl: '',
workflowCount: 0
}
]
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
@@ -299,7 +313,8 @@ function run(): void {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`
}`,
{ cause: error }
)
}
}
@@ -367,7 +382,7 @@ function run(): void {
displayName: m.name
}))
const combined = [...apiOutput, ...output]
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(

View File

@@ -7,12 +7,16 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
/>
<!-- Desktop hover overlay -->
<div

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
>
<h2
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const {
locale = 'en',
modelName,
ctaHref,
videoSrc,
videoAriaLabel
} = defineProps<{
locale?: Locale
modelName: string
ctaHref: string
videoSrc: string
videoAriaLabel?: string
}>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
<h1
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
}}
<span class="text-primary-comfy-yellow">ComfyUI</span>
{{
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
}}
</h1>
<p
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-10 px-8 py-4 uppercase"
>
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
</BrandButton>
<div class="mt-16 w-full max-w-5xl">
<video
:src="videoSrc"
:aria-label="videoAriaLabel || undefined"
:aria-hidden="videoAriaLabel ? undefined : true"
autoplay
loop
muted
playsinline
preload="metadata"
class="rounded-4.5xl size-full object-cover"
/>
</div>
</section>
</template>

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
</p>
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-24 w-full">
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
>
<span class="lg:hidden">{{
t('cloud.aiModels.ctaMobile', locale)
}}</span>
<span class="hidden lg:inline">{{
<!-- <span class="lg:hidden"> -->
{{ t('cloud.aiModels.ctaMobile', locale) }}
<!-- </span> -->
<!-- <span class="hidden lg:inline">{{
t('cloud.aiModels.ctaDesktop', locale)
}}</span>
}}</span> -->
</BrandButton>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
'grok-imagine': {
hubSlug: 'grok',
featured: false
},

View File

@@ -7,6 +7,8 @@ export interface GalleryItem {
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}

View File

@@ -4634,6 +4634,80 @@ const translations = {
'zh-CN': '支持的模型'
},
// Models list page (/models)
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
'models.list.heroCta': {
en: 'Try {name} Now',
'zh-CN': '立即试用 {name}'
},
'models.list.creations.title': {
en: '{name} Image and Video Creations',
'zh-CN': '{name} 图像与视频创作'
},
'models.list.creations.cta': {
en: 'Explore Workflows',
'zh-CN': '探索工作流'
},
'models.list.heroTitle.before': {
en: '{name} in',
'zh-CN': ''
},
'models.list.heroTitle.after': {
en: '',
'zh-CN': ' 中的 {name}'
},
'models.list.heroSubtitle': {
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
'zh-CN':
'从开源扩散模型到合作伙伴 API涵盖每一个主流模型并附带可直接运行的社区工作流模板。'
},
'models.list.card.workflows': {
en: '{count} workflows',
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
en: 'Run the worlds\nleading AI models',
'zh-CN': '运行全球领先的\nAI 模型'
},
'models.showcase.subtitle': {
en: 'New models are added as they launch.',
'zh-CN': '新模型发布后会第一时间上线。'
},
'models.showcase.cta': {
en: 'EXPLORE WORKFLOWS',
'zh-CN': '探索工作流'
},
'models.showcase.card.grokImagine': {
en: 'Grok Imagine',
'zh-CN': 'Grok Imagine'
},
'models.showcase.card.nanoBananaPro': {
en: 'Nano Banana Pro',
'zh-CN': 'Nano Banana Pro'
},
'models.showcase.card.ltx23': {
en: 'LTX 2.3',
'zh-CN': 'LTX 2.3'
},
'models.showcase.card.qwenAdvancedEdit': {
en: 'Advanced image\nediting with Qwen',
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
},
'models.showcase.card.wan22TextToVideo': {
en: 'Wan 2.2\ntext to video',
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',

View File

@@ -0,0 +1,22 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="Models — Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection
modelName="Grok Imagine"
ctaHref="/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="模型 — Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection
locale="zh-CN"
modelName="Grok Imagine"
ctaHref="/zh-CN/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -69,11 +69,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -93,16 +93,7 @@ describe('TabErrors.vue', () => {
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
missingMediaTitle: 'Missing Inputs'
}
}
}
@@ -468,6 +459,50 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('renders one missing media item per referencing node and locates the selected node', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'3': 'First Loader',
'4': 'Second Loader'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',

View File

@@ -256,7 +256,6 @@
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>

View File

@@ -131,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
function makeMissingNodeType(
type: string,
@@ -178,6 +179,24 @@ function makeModel(
}
}
function makeMedia(
name: string,
opts: {
nodeId: string | number
nodeType?: string
widgetName?: string
}
): MissingMediaCandidate {
return {
name,
nodeId: opts.nodeId,
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
isMissing: true
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
@@ -1060,6 +1079,27 @@ describe('useErrorGroups', () => {
groups.missingModelGroups.value
)
})
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
])
await nextTick()
expect(store.totalErrorCount).toBe(2)
expect(groups.missingMediaGroups.value).toHaveLength(1)
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
expect(
groups.missingMediaGroups.value[0].items[0].referencingNodes
).toHaveLength(2)
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})
describe('tabErrorGroups', () => {

View File

@@ -34,6 +34,7 @@ import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
@@ -690,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
return [
{
type: 'missing_media' as const,
@@ -702,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}
@@ -806,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
)
return [
{
@@ -818,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}

View File

@@ -3671,21 +3671,7 @@
"refreshFailed": "Failed to refresh missing models. Please try again."
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library",
"confirmSelection": "Confirm selection",
"cancelSelection": "Cancel selection",
"or": "OR"
"missingMediaTitle": "Missing Inputs"
}
},
"errorOverlay": {
@@ -3736,6 +3722,7 @@
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel,
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
@@ -1579,6 +1580,32 @@ describe('errorMessageResolver', () => {
})
})
it.for([
{
source: { nodeType: 'LoadImage', widgetName: 'image' },
displayItemLabel: 'Load Image - image'
},
{
source: {
nodeDisplayName: 'Custom Loader',
nodeType: 'LoadImage',
widgetName: 'image'
},
displayItemLabel: 'Custom Loader - image'
},
{
source: { nodeType: '', widgetName: '' },
displayItemLabel: 'This node - unknown input'
}
] as const)(
'resolves missing media item labels from $source',
({ source, displayItemLabel }) => {
expect(resolveMissingMediaItemLabel(source)).toEqual({
displayItemLabel
})
}
)
it.for([
[
'image',
@@ -1649,6 +1676,44 @@ describe('errorMessageResolver', () => {
}
)
it('summarizes a shared missing media file by affected node references', () => {
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'shared.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'image' },
{ nodeId: '2', widgetName: 'image' }
]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')

View File

@@ -5,13 +5,15 @@ import type {
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel
} from './missingErrorResolver'
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>

View File

@@ -2,7 +2,8 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { translateCatalogMessage } from './catalogI18n'
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
@@ -255,6 +256,12 @@ type MissingMediaSource = Extract<
{ kind: 'missing_media' }
>
interface MissingMediaItemLabelSource {
nodeDisplayName?: string
nodeType?: string
widgetName?: string
}
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
@@ -272,9 +279,29 @@ function resolveMissingMediaDisplayMessage(): string {
)
}
export function resolveMissingMediaItemLabel(
source: MissingMediaItemLabelSource
): { displayItemLabel: string } {
const nodeName = normalizeNodeName(
source.nodeDisplayName ||
formatNodeTypeName(source.nodeType ?? '') ||
undefined
)
const inputName =
source.widgetName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
return {
displayItemLabel: translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.itemLabel',
'{nodeName} - {inputName}',
{ nodeName, inputName }
)
}
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
if (countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
@@ -290,7 +317,7 @@ function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
if (!firstItem || countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'

View File

@@ -1,50 +1,61 @@
<template>
<div class="px-4 pb-2">
<div
v-for="group in missingMediaGroups"
:key="group.mediaType"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
<TransitionGroup
tag="ul"
name="list-scale"
class="relative m-0 list-none space-y-1 p-0"
>
<!-- Media type header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
>
<i
aria-hidden="true"
:class="MEDIA_TYPE_ICONS[group.mediaType]"
class="mr-1 size-3.5 align-text-bottom"
/>
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
({{ group.items.length }})
</p>
</div>
<!-- Media file rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingMediaRow
v-for="item in group.items"
:key="item.name"
:item="item"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
/>
</div>
</div>
<li
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
</button>
</span>
<Button
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel
})
"
@click.stop="emit('locateNode', item.nodeId)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { app } from '@/scripts/app'
import { st } from '@/i18n'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -53,9 +64,60 @@ const emit = defineEmits<{
const { t } = useI18n()
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
image: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
audio: 'icon-[lucide--music]'
interface MissingMediaItemEntry {
key: string
nodeId: string
displayItemLabel: string
}
const missingMediaItems = computed(() => {
return getMissingMediaReferences(missingMediaGroups)
.map(({ mediaItem, nodeRef }) => {
const nodeId = String(nodeRef.nodeId)
return {
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
nodeId,
displayItemLabel: getDisplayItemLabel(
nodeId,
nodeRef.nodeType ?? mediaItem.representative.nodeType,
nodeRef.widgetName
)
}
})
.sort(compareMissingMediaItems)
})
function getDisplayItemLabel(
nodeId: string,
nodeType: string,
widgetName: string
) {
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
return resolveMissingMediaItemLabel({
nodeDisplayName,
nodeType,
widgetName
}).displayItemLabel
}
function compareMissingMediaItems(
a: MissingMediaItemEntry,
b: MissingMediaItemEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.displayItemLabel.localeCompare(b.displayItemLabel)
)
}
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, nodeId)
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
</script>

View File

@@ -1,147 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingMedia.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent class="max-h-72">
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
<div class="flex items-center gap-2">
<img
v-if="mediaType === 'image'"
:src="getPreviewUrl(option.value)"
alt=""
class="size-8 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<video
v-else-if="mediaType === 'video'"
aria-hidden="true"
:src="getPreviewUrl(option.value)"
class="size-8 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
/>
<span class="min-w-0 truncate">{{ option.name }}</span>
</div>
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import type { MediaType } from '@/platform/missingMedia/types'
import { api } from '@/scripts/api'
const {
options,
showDivider = false,
mediaType
} = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
mediaType: MediaType
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const SEARCH_THRESHOLD = 4
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function getPreviewUrl(filename: string): string {
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
}
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,318 +0,0 @@
<template>
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file] size-4 shrink-0"
/>
<!-- Single node: show node display name instead of filename -->
<template v-if="isSingleNode">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ item.referencingNodes[0].nodeId }}
</span>
<p
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="singleNodeLabel"
>
{{ singleNodeLabel }}
</p>
</template>
<!-- Multiple nodes: show filename with count -->
<p
v-else
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
({{ item.referencingNodes.length }})
</p>
<!-- Confirm button (visible when pending selection exists) -->
<Button
data-testid="missing-media-confirm-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
:disabled="!isPending"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="confirmSelection(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="isPending ? 'text-primary' : 'text-foreground'"
/>
</Button>
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<!-- Expand button (multiple nodes only) -->
<Button
v-if="!isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingMedia.collapseNodes')
: t('rightSidePanel.missingMedia.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleExpand(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Referencing nodes (expandable) -->
<TransitionCollapse>
<div
v-if="expanded && item.referencingNodes.length > 1"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="nodeRef in item.referencingNodes"
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeRef.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(nodeRef.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card (uploading, uploaded, or library select) -->
<TransitionCollapse>
<div
v-if="isPending || isUploading"
data-testid="missing-media-status-card"
role="status"
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="currentUpload?.status === 'uploading'"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ pendingDisplayName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="currentUpload?.status === 'uploading'">
{{ t('rightSidePanel.missingMedia.uploading') }}
</template>
<template v-else-if="currentUpload?.status === 'uploaded'">
{{ t('rightSidePanel.missingMedia.uploaded') }}
</template>
<template v-else>
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
</template>
</span>
</div>
<Button
data-testid="missing-media-cancel-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="cancelSelection(item.name)"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Upload + Library (when no pending selection) -->
<TransitionCollapse>
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
<!-- Upload dropzone -->
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
<button
data-testid="missing-media-upload-dropzone"
type="button"
:class="
cn(
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
isOverDropZone && 'border-primary text-primary'
)
"
@click="openFilePicker()"
>
{{
t('rightSidePanel.missingMedia.uploadFile', {
type: extensionHint
})
}}
</button>
</div>
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
data-testid="missing-media-library-select"
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"
:media-type="item.mediaType"
@select="handleLibrarySelect(item.name, $event)"
/>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDropZone, useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
useMissingMediaInteractions,
getNodeDisplayLabel,
getMediaDisplayName
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
const { item, showNodeIdBadge } = defineProps<{
item: MissingMediaViewModel
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
}>()
const { t } = useI18n()
const store = useMissingMediaStore()
const { uploadState, pendingSelection } = storeToRefs(store)
const {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
} = useMissingMediaInteractions()
const displayName = getMediaDisplayName(item.name)
const isSingleNode = item.referencingNodes.length === 1
const singleNodeLabel = isSingleNode
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
: ''
const acceptType = getAcceptType(item.mediaType)
const extensionHint = getExtensionHint(item.mediaType)
const expanded = computed(() => isExpanded(item.name))
const matchingCandidate = computed(() => {
const candidates = store.missingMediaCandidates
if (!candidates?.length) return null
return candidates.find((c) => c.name === item.name) ?? null
})
const libraryOptions = computed(() => {
const candidate = matchingCandidate.value
if (!candidate) return []
return getLibraryOptions(candidate)
})
const isPending = computed(() => hasPendingSelection(item.name))
const isUploading = computed(
() => uploadState.value[item.name]?.status === 'uploading'
)
const currentUpload = computed(() => uploadState.value[item.name])
const pendingDisplayName = computed(() => {
if (currentUpload.value) return currentUpload.value.fileName
const pending = pendingSelection.value[item.name]
return pending ? getMediaDisplayName(pending) : ''
})
const dropZoneRef = ref<HTMLElement | null>(null)
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (_files, event) => {
event?.stopPropagation()
const file = _files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
}
})
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
accept: acceptType,
multiple: false
})
onFileSelected((files) => {
const file = files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
})
</script>

View File

@@ -1,224 +0,0 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isCloud } from '@/platform/distribution/types'
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
return resolveComboValues(result.widget)
.filter((v) => v !== candidate.name)
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
const assetsStore = useAssetsStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveLibraryOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
useToastStore().addAlert(
st(
'toastMessages.unsupportedFileType',
'Unsupported file type. Please select a valid file.'
)
)
return
}
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
// Refresh assets store (non-critical — upload already succeeded)
try {
await assetsStore.updateInputs()
} catch {
// Asset list refresh failed but upload is valid; selection can proceed
}
} catch {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -0,0 +1,26 @@
import { sumBy } from 'es-toolkit'
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
export interface MissingMediaReference {
mediaItem: MissingMediaViewModel
nodeRef: MissingMediaViewModel['referencingNodes'][number]
}
export function getMissingMediaReferences(
groups: MissingMediaGroup[]
): MissingMediaReference[] {
return groups.flatMap((group) =>
group.items.flatMap((mediaItem) =>
mediaItem.referencingNodes.map((nodeRef) => ({ mediaItem, nodeRef }))
)
)
}
export function countMissingMediaReferences(
groups: MissingMediaGroup[]
): number {
return sumBy(groups, (group) =>
sumBy(group.items, (item) => item.referencingNodes.length)
)
}

View File

@@ -16,6 +16,10 @@ import {
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import {
countMissingMediaReferences,
getMissingMediaReferences
} from './missingMediaGrouping'
import type { MissingMediaCandidate } from './types'
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
@@ -421,6 +425,11 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.referencingNodes[0]).toMatchObject({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
})
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
@@ -487,6 +496,27 @@ describe('groupCandidatesByMediaType', () => {
})
})
describe('missing media references', () => {
it('flattens references without deduping shared filenames', () => {
const groups = groupCandidatesByMediaType([
makeCandidate('1', 'shared.png'),
makeCandidate('2', 'shared.png'),
makeCandidate('3', 'other.png')
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toHaveLength(2)
expect(countMissingMediaReferences(groups)).toBe(3)
expect(
getMissingMediaReferences(groups).map(({ nodeRef }) => nodeRef)
).toEqual([
expect.objectContaining({ nodeId: '1' }),
expect.objectContaining({ nodeId: '2' }),
expect.objectContaining({ nodeId: '3' })
])
})
})
describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'

View File

@@ -256,6 +256,7 @@ export function groupCandidatesByName(
if (existing) {
existing.referencingNodes.push({
nodeId: c.nodeId,
nodeType: c.nodeType,
widgetName: c.widgetName
})
} else {
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
referencingNodes: [
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
]
})
}
}

View File

@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('clearMissingMedia resets all state including interaction state', () => {
it('clearMissingMedia resets candidates and aborts verification', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
const controller = store.createVerificationAbortController()
store.clearMissingMedia()
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
expect(store.missingMediaCount).toBe(0)
expect(controller.signal.aborted).toBe(true)
expect(store.expandState).toEqual({})
expect(store.uploadState).toEqual({})
expect(store.pendingSelection).toEqual({})
})
it('missingMediaNodeIds tracks unique node IDs', () => {
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removeMissingMediaByName clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByName('photo.png')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('createVerificationAbortController aborts previous controller', () => {
const store = useMissingMediaStore()
const first = store.createVerificationAbortController()
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
function clearInteractionStateForName(name: string) {
delete expandState.value[name]
delete uploadState.value[name]
delete pendingSelection.value[name]
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
clearInteractionStateForName(name)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter(
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
if (!String(m.nodeId).startsWith(prefix)) {
remaining.push(m)
}
}
if (removedNames.size === 0) return
if (remaining.length === missingMediaCandidates.value.length) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
isContainerWithMissingMedia
}
})

View File

@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
nodeType?: string
widgetName: string
}>
}

View File

@@ -35,12 +35,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
let testId = 0
beforeEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
delete window.__comfyDesktop2
delete window.__comfyDesktop2Remote
})
describe('fetchModelMetadata', () => {
beforeEach(() => {
fetchMock.mockReset()
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
testId++
})
@@ -242,7 +247,126 @@ describe('downloadModel', () => {
beforeEach(() => {
mockIsDesktop.value = false
mockSidebarTabStore.activeSidebarTabId = null
mockStartDownload.mockReset()
})
it('uses the Desktop2 bridge directly instead of the browser fallback', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).toHaveBeenCalledWith(
'https://huggingface.co/org/model/resolve/main/model.safetensors',
'model.safetensors',
'checkpoints'
)
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('logs Desktop2 bridge failures without falling back to browser download', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const bridgeError = new Error('bridge failed')
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockRejectedValue(bridgeError)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to start Desktop2 model download:',
bridgeError
)
})
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('logs synchronous Desktop2 bridge failures without crashing', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const bridgeError = new Error('bridge failed before returning a promise')
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockImplementation(() => {
throw bridgeError
})
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to start Desktop2 model download:',
bridgeError
)
})
expect(anchorClick).not.toHaveBeenCalled()
expect(mockStartDownload).not.toHaveBeenCalled()
})
it('keeps remote Desktop2 sessions on the browser fallback', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2Remote = true
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).not.toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalledTimes(1)
})
it('opens the model library sidebar before starting a desktop download', () => {

View File

@@ -3,6 +3,21 @@ import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://civitai.red/',
@@ -35,6 +50,17 @@ export interface ModelWithUrl {
directory: string
}
async function startDesktop2ModelDownload(
bridge: ComfyDesktop2Bridge,
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
@@ -63,6 +89,12 @@ export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url

View File

@@ -208,6 +208,23 @@ describe('GtmTelemetryProvider', () => {
expect(entry!.error as string).toHaveLength(100)
})
it('pushes execution_start', () => {
const provider = createInitializedProvider()
provider.trackWorkflowExecution()
expect(lastDataLayerEntry()).toMatchObject({
event: 'execution_start'
})
})
it('pushes execution_success with job_id', () => {
const provider = createInitializedProvider()
provider.trackExecutionSuccess({ jobId: 'job-1' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'execution_success',
job_id: 'job-1'
})
})
it('pushes select_content for template events', () => {
const provider = createInitializedProvider()
provider.trackTemplate({

View File

@@ -59,8 +59,6 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ShareFlowMetadata,
SurveyResponses,
TemplateLibraryClosedMetadata,
@@ -288,8 +286,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
}
const enterLinearMetadata: EnterLinearMetadata = {}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
const authMetadata: AuthMetadata = {}
it.for<
@@ -355,16 +351,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackShareFlow(shareFlowMetadata),
TelemetryEvents.SHARE_FLOW
],
[
'trackExecutionError',
(p) => p.trackExecutionError(executionErrorMetadata),
TelemetryEvents.EXECUTION_ERROR
],
[
'trackExecutionSuccess',
(p) => p.trackExecutionSuccess(executionSuccessMetadata),
TelemetryEvents.EXECUTION_SUCCESS
],
[
'trackAuth',
(p) => p.trackAuth(authMetadata),
@@ -422,27 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
})
)
})
it('trackWorkflowExecution forwards the latest trigger_source from trackRunButton', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
provider.trackRunButton({ trigger_source: 'keybinding' })
provider.trackWorkflowExecution()
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.EXECUTION_START,
expect.objectContaining({ trigger_source: 'keybinding' })
)
mockMixpanel.track.mockClear()
provider.trackWorkflowExecution()
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.EXECUTION_START,
expect.objectContaining({ trigger_source: 'unknown' })
)
})
})
describe('MixpanelTelemetryProvider — topup delegation', () => {

View File

@@ -18,10 +18,7 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -92,7 +89,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
@@ -300,7 +296,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
@@ -420,24 +415,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}

View File

@@ -14,9 +14,6 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
@@ -102,7 +99,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private eventQueue: QueuedEvent[] = []
private pendingFirstAuthAt = new Map<string, string>()
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
@@ -400,7 +396,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
@@ -532,24 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}