Compare commits

...

7 Commits

Author SHA1 Message Date
GitHub Action
e6668e1bb6 [automated] Apply ESLint and Oxfmt fixes 2026-03-25 18:25:50 +00:00
MillerMedia
5fba682609 [chore] Update Ingest API types from cloud@2aa5167 2026-03-25 18:22:47 +00:00
Christian Byrne
437f41c553 perf: add layout/GC metrics + reduce false positives in regression detection (#10477)
## Summary

Add layout duration, style recalc duration, and heap usage metrics to CI
perf reports, while improving statistical reliability to reduce false
positive regressions.

## Changes

- **What**:
- Collect `layoutDurationMs`, `styleRecalcDurationMs`, `heapUsedBytes`
(absolute snapshot) alongside existing metrics
- Add effect size gate (`minAbsDelta`) for integer-quantized count
metrics (style recalcs, layouts, DOM nodes, event listeners) — prevents
z=7.2 false positives from e.g. 11→12 style recalcs
- Switch from mean to **median** for PR metric aggregation — robust to
outlier CI runs that dominate n=3 mean
- Increase historical baseline window from **5 to 15 runs** for more
stable σ estimates
- Reorder reported metrics: layout/style duration first (actionable),
counts and heap after (informational)

## Review Focus

The effect size gate in `classifyChange()` — it now requires both z > 2
AND absolute delta ≥ `minAbsDelta` (when configured) to flag a
regression. This addresses the core false positive issue where integer
metrics with near-zero historical variance produce extreme z-scores for
trivial changes.

Median vs mean tradeoff: median is more robust to outliers but less
sensitive to real shifts — acceptable given n=3 and CI noise levels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10477-perf-add-layout-GC-metrics-reduce-false-positives-in-regression-detection-32d6d73d365081daa72cec96d8a07b90)
by [Unito](https://www.unito.io)
2026-03-25 10:16:56 -07:00
ComfyUI Wiki
975393b48b fix: restore is_template tracking for app mode templates (#10252)
## Summary

App mode templates (names ending in `.app`, e.g.
`templates-qwen_multiangle.app`) were never counted as template
executions in Mixpanel because `getExecutionContext` used
`activeWorkflow.filename` for the `knownTemplateNames` lookup — but
`getFilenameDetails` treats `.app.json` as a compound extension and
strips it entirely, leaving `"templates-qwen_multiangle"` instead of
`"templates-qwen_multiangle.app"`. The set lookup always returned
`false`, so every execution was sent with `is_template: false`.

## Changes

- **Fix**: derive the template lookup key from
`fullFilename.replace(/\.json$/i, '')` instead of `filename`, which
preserves the `.app` suffix and correctly matches `knownTemplateNames`
- **Also fixes**: `workflow_name`, `getTemplateByName`, and
`getEnglishMetadata` calls in the same branch now use the corrected name
- **Tests**: three new cases in `MixpanelTelemetryProvider.test.ts` —
regular template, `.app` template (regression), and non-template

## Before / After

| Template name in index | `activeWorkflow.filename` | `fullFilename` →
stripped | `is_template` |
|---|---|---|---|
| `flux-dev` | `flux-dev` | `flux-dev` |  true |
| `templates-qwen_multiangle.app` | `templates-qwen_multiangle`  |
`templates-qwen_multiangle.app`  | fixed: true |

## Review Focus

The change is confined to `getExecutionContext.ts`. `fullFilename` is
always set (it is assigned in `UserFile` constructor from
`getPathDetails`), so no null-safety issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10252-fix-restore-is_template-tracking-for-app-mode-templates-3276d73d365081d4b998edc62ad010dc)
by [Unito](https://www.unito.io)
2026-03-25 21:31:09 +08:00
Luke Mino-Altherr
a44fa1fdd5 fix: tighten date detection regex in formatJsonValue() (#10110)
`formatJsonValue()` uses a loose regex `^\d{4}-\d{2}-\d{2}` to detect
date-like strings, which matches non-date strings like
`"2024-01-01-beta"`.

Changes:
- Require ISO 8601 `T` separator: `/^\d{4}-\d{2}-\d{2}T/`
- Validate parse result with `!Number.isNaN(date.getTime())`
- Use `d()` i18n formatter for consistency with `formatDate()` in the
same file
2026-03-24 19:46:58 -07:00
Christian Byrne
cc3acebceb feat: scaffold Astro 5 website app + design-system base.css [1/3] (#10140)
## Summary
Scaffolds the new apps/website/ Astro 5 + Vue 3 marketing site inside
the monorepo.

## Changes
- apps/website/ with package.json, astro.config.mjs, tsconfig, Nx
targets
- @comfyorg/design-system/css/base.css — brand tokens + fonts (no
PrimeVue)
- pnpm-workspace.yaml catalog entries for Astro deps
- .gitignore and env.d.ts for Astro

## Stack (via Graphite)
- **[1/3] Scaffold** ← this PR
- #10141 [2/3] Layout Shell
- #10142 [3/3] Homepage Sections

Part of the comfy.org website refresh (replacing Framer).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10140-feat-scaffold-Astro-5-website-app-design-system-base-css-1-3-3266d73d365081688dcee0220a03eca4)
by [Unito](https://www.unito.io)
2026-03-24 19:02:10 -07:00
Alexander Brown
23c22e4c52 🧙 feat: wire ComfyHub publish wizard with profile gate, asset upload, and submission (#10128)
## Summary 🎯

Wire the ComfyHub publish flow end-to-end: profile gate, multi-step
wizard (describe, examples, finish), asset upload, and workflow
submission via hub API.

> *A wizard of steps, from describe to the end,*
> *Upload your assets, your workflows you'll send!*
> *With tags neatly slugged and thumbnails in place,*
> *Your ComfyHub publish is ready to race!* 🏁

## Changes 🔧

- 🌐 **Hub Service** — `comfyHubService` for profile CRUD, presigned
asset uploads, and workflow publish
- 📦 **Submission** — `useComfyHubPublishSubmission` orchestrates file
uploads → publish in one flow
- 🧙 **Wizard Steps** — Describe (name/description/tags) → Examples
(drag-drop reorderable images) → Thumbnail → Finish (profile card +
private-asset warnings)
- 🖼️ **ReorderableExampleImage** — Drag-drop *and* keyboard reordering,
accessible and fun
- 🏷️ **Tag Normalization** — `normalizeTags` slugifies before publishing
- 🔄 **Re-publish Prefill** — Fetches hub workflow metadata on
re-publish, with in-memory cache fallback
- 📐 **Schema Split** — Publish-record schema separated from
hub-workflow-metadata schema
- 🙈 **Unlisted Skip** — No hub-detail prefill fetch for unlisted records
- 👤 **Profile Gate** — Username validation in `useComfyHubProfileGate`
- 🧪 **Tests Galore** — New suites for DescribeStep, ExamplesStep,
WizardContent, PublishSubmission, comfyHubService, normalizeTags, plus
expanded PublishDialog & workflowShareService coverage

## Review Focus 🔍

> *Check the service, the schema, the Zod validation too,*
> *The upload orchestration — does it carry things through?*
> *The prefill fetch strategy: status → detail → cache,*
> *And drag-drop reordering — is it keeping its place?* 🤔

- 🌐 `comfyHubService.ts` — API contract shape, error handling, Zod
parsing
- 📦 `useComfyHubPublishSubmission.ts` — Upload-then-publish flow, edge
cases (no profile, no workflow)
- 🗂️ `ComfyHubPublishDialog.vue` — Prefill fetch strategy (publish
status → hub detail → cache)
- 🖼️ `ReorderableExampleImage.vue` — Drag-drop + keyboard a11y

## Testing 🧪

```bash
pnpm test:unit -- src/platform/workflow/sharing/
pnpm typecheck
```

> *If the tests all turn green and the types all align,*
> *Then merge it on in — this publish flow's fine!* 

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-25 09:30:25 +09:00
56 changed files with 5980 additions and 547 deletions

View File

@@ -180,7 +180,7 @@ jobs:
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
git fetch origin perf-data --depth=1
mkdir -p temp/perf-history
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
done
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"

2
apps/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
.astro/

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'astro/config'
import vue from '@astrojs/vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
integrations: [vue()],
vite: {
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false
}
}
})

80
apps/website/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "@comfyorg/website",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@comfyorg/design-system": "workspace:*",
"@vercel/analytics": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/vue": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
}
}
}
}

Binary file not shown.

Binary file not shown.

1
apps/website/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import '@comfyorg/design-system/css/base.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "astro.config.mjs"]
}

View File

@@ -23,6 +23,7 @@ export interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -190,6 +191,7 @@ export class PerformanceHelper {
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize'),
heapUsedBytes: after.JSHeapUsedSize,
domNodes: delta('Nodes'),
jsHeapTotalBytes: delta('JSHeapTotalSize'),
scriptDurationMs: delta('ScriptDuration') * 1000,

View File

@@ -27,6 +27,17 @@ const config: KnipConfig = {
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
},
'apps/website': {
entry: [
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3'],

View File

@@ -0,0 +1,46 @@
/*
* Design System Base — Brand tokens + fonts only.
* For marketing sites that don't use PrimeVue or the node editor.
* Import the full style.css instead for the desktop app.
*/
@import './fonts.css';
@theme {
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-ash-300: #bbbbbb;
--color-ash-500: #828282;
--color-ash-800: #444444;
--color-smoke-100: #f3f3f3;
--color-smoke-200: #e9e9e9;
--color-smoke-300: #e1e1e1;
--color-smoke-400: #d9d9d9;
--color-smoke-500: #c5c5c5;
--color-smoke-600: #b4b4b4;
--color-smoke-700: #a0a0a0;
--color-smoke-800: #8a8a8a;
--color-white: #ffffff;
--color-black: #000000;
/* Brand Colors */
--color-electric-400: #f0ff41;
--color-sapphire-700: #172dd7;
--color-brand-yellow: var(--color-electric-400);
--color-brand-blue: var(--color-sapphire-700);
}

View File

@@ -16,6 +16,7 @@ export type {
AssetCreated,
AssetCreatedWritable,
AssetDownloadResponse,
AssetInfo,
AssetMetadataResponse,
AssetTagHistogramResponse,
AssetUpdated,
@@ -38,6 +39,11 @@ export type {
CheckAssetByHashError,
CheckAssetByHashErrors,
CheckAssetByHashResponses,
CheckHubUsernameData,
CheckHubUsernameError,
CheckHubUsernameErrors,
CheckHubUsernameResponse,
CheckHubUsernameResponses,
ClaimInviteCodeData,
ClaimInviteCodeError,
ClaimInviteCodeErrors,
@@ -62,7 +68,19 @@ export type {
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
CreateHubAssetUploadUrlResponse,
CreateHubAssetUploadUrlResponses,
CreateHubProfileData,
CreateHubProfileError,
CreateHubProfileErrors,
CreateHubProfileRequest,
CreateHubProfileResponse,
CreateHubProfileResponses,
CreateInviteRequest,
CreateSecretData,
CreateSecretError,
@@ -111,6 +129,11 @@ export type {
DeleteAssetErrors,
DeleteAssetResponse,
DeleteAssetResponses,
DeleteHubWorkflowData,
DeleteHubWorkflowError,
DeleteHubWorkflowErrors,
DeleteHubWorkflowResponse,
DeleteHubWorkflowResponses,
DeleteSecretData,
DeleteSecretError,
DeleteSecretErrors,
@@ -212,6 +235,16 @@ export type {
GetGlobalSubgraphsErrors,
GetGlobalSubgraphsResponse,
GetGlobalSubgraphsResponses,
GetHubProfileByUsernameData,
GetHubProfileByUsernameError,
GetHubProfileByUsernameErrors,
GetHubProfileByUsernameResponse,
GetHubProfileByUsernameResponses,
GetHubWorkflowData,
GetHubWorkflowError,
GetHubWorkflowErrors,
GetHubWorkflowResponse,
GetHubWorkflowResponses,
GetInviteCodeStatusData,
GetInviteCodeStatusError,
GetInviteCodeStatusErrors,
@@ -250,11 +283,21 @@ export type {
GetModelsInFolderErrors,
GetModelsInFolderResponse,
GetModelsInFolderResponses,
GetMyHubProfileData,
GetMyHubProfileError,
GetMyHubProfileErrors,
GetMyHubProfileResponse,
GetMyHubProfileResponses,
GetPaymentPortalData,
GetPaymentPortalError,
GetPaymentPortalErrors,
GetPaymentPortalResponse,
GetPaymentPortalResponses,
GetPublishedWorkflowData,
GetPublishedWorkflowError,
GetPublishedWorkflowErrors,
GetPublishedWorkflowResponse,
GetPublishedWorkflowResponses,
GetRawLogsData,
GetRawLogsError,
GetRawLogsErrors,
@@ -305,11 +348,30 @@ export type {
GetWorkspaceResponses,
GlobalSubgraphData,
GlobalSubgraphInfo,
HubAssetUploadUrlRequest,
HubAssetUploadUrlResponse,
HubLabelInfo,
HubLabelListResponse,
HubProfile,
HubProfileSummary,
HubUsernameCheckResponse,
HubWorkflowDetail,
HubWorkflowListResponse,
HubWorkflowSummary,
HubWorkflowTemplateEntry,
ImportPublishedAssetsData,
ImportPublishedAssetsError,
ImportPublishedAssetsErrors,
ImportPublishedAssetsRequest,
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InviteCodeClaimResponse,
InviteCodeStatusResponse,
JobStatusResponse,
JwkKey,
JwksResponse,
LabelRef,
LeaveWorkspaceData,
LeaveWorkspaceError,
LeaveWorkspaceErrors,
@@ -322,6 +384,21 @@ export type {
ListAssetsResponse2,
ListAssetsResponses,
ListAssetsResponseWritable,
ListHubLabelsData,
ListHubLabelsError,
ListHubLabelsErrors,
ListHubLabelsResponse,
ListHubLabelsResponses,
ListHubWorkflowIndexData,
ListHubWorkflowIndexError,
ListHubWorkflowIndexErrors,
ListHubWorkflowIndexResponse,
ListHubWorkflowIndexResponses,
ListHubWorkflowsData,
ListHubWorkflowsError,
ListHubWorkflowsErrors,
ListHubWorkflowsResponse,
ListHubWorkflowsResponses,
ListInvitesResponse,
ListMembersResponse,
ListSecretsData,
@@ -376,6 +453,11 @@ export type {
PlanAvailability,
PlanAvailabilityReason,
PlanSeatSummary,
PostAssetsFromWorkflowData,
PostAssetsFromWorkflowError,
PostAssetsFromWorkflowErrors,
PostAssetsFromWorkflowResponse,
PostAssetsFromWorkflowResponses,
PreviewPlanInfo,
PreviewSubscribeData,
PreviewSubscribeError,
@@ -384,6 +466,13 @@ export type {
PreviewSubscribeResponse,
PreviewSubscribeResponse2,
PreviewSubscribeResponses,
PublishedWorkflowDetail,
PublishHubWorkflowData,
PublishHubWorkflowError,
PublishHubWorkflowErrors,
PublishHubWorkflowRequest,
PublishHubWorkflowResponse,
PublishHubWorkflowResponses,
RawLogsResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
@@ -455,6 +544,12 @@ export type {
UpdateAssetTagsErrors,
UpdateAssetTagsResponse,
UpdateAssetTagsResponses,
UpdateHubProfileData,
UpdateHubProfileError,
UpdateHubProfileErrors,
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateSecretData,
UpdateSecretError,
UpdateSecretErrors,
@@ -486,6 +581,8 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
WorkflowApiAssetsRequest,
WorkflowApiAssetsResponse,
WorkflowForkedFrom,
WorkflowListResponse,
WorkflowResponse,

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,207 @@
import { z } from 'zod'
export const zHubUsernameCheckResponse = z.object({
username: z.string(),
available: z.boolean(),
suggestions: z.array(z.string()).optional(),
validation_error: z.string().optional()
})
export const zHubAssetUploadUrlResponse = z.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
export const zHubAssetUploadUrlRequest = z.object({
filename: z.string(),
content_type: z.string()
})
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
workflow_filename: z.string(),
asset_ids: z.array(z.string()),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
models: z.array(z.string()).optional(),
custom_nodes: z.array(z.string()).optional(),
tutorial_url: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_token_or_url: z.string().optional(),
thumbnail_comparison_token_or_url: z.string().optional(),
sample_image_tokens_or_urls: z.array(z.string()).optional()
})
export const zAssetInfo = z.object({
id: z.string(),
name: z.string(),
preview_url: z.string(),
storage_url: z.string(),
model: z.boolean(),
public: z.boolean(),
in_library: z.boolean()
})
export const zHubProfileSummary = z.object({
username: z.string(),
display_name: z.string().optional(),
avatar_url: z.string().optional()
})
export const zLabelRef = z.object({
name: z.string(),
display_name: z.string()
})
export const zHubWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
name: z.string(),
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_url: z.string().optional(),
thumbnail_comparison_url: z.string().optional(),
models: z.array(zLabelRef).optional(),
custom_nodes: z.array(zLabelRef).optional(),
tutorial_url: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
sample_image_urls: z.array(z.string()).optional(),
publish_time: z.string().datetime().nullish(),
workflow_json: z.record(z.unknown()),
assets: z.array(zAssetInfo),
profile: zHubProfileSummary
})
export const zHubWorkflowSummary = z.object({
share_id: z.string(),
name: z.string(),
description: z.string().optional(),
tags: z.array(zLabelRef).optional(),
models: z.array(zLabelRef).optional(),
custom_nodes: z.array(zLabelRef).optional(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional(),
thumbnail_url: z.string().optional(),
thumbnail_comparison_url: z.string().optional(),
publish_time: z.string().datetime().nullish(),
profile: zHubProfileSummary,
metadata: z.record(z.unknown()).optional(),
tutorial_url: z.string().optional(),
sample_image_urls: z.array(z.string()).optional()
})
export const zHubWorkflowListResponse = z.object({
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
next_cursor: z.string().optional()
})
export const zHubLabelInfo = z.object({
name: z.string(),
display_name: z.string(),
description: z.string().optional(),
type: z.enum(['tag', 'model', 'custom_node'])
})
export const zHubLabelListResponse = z.object({
labels: z.array(zHubLabelInfo)
})
export const zHubWorkflowTemplateEntry = z.object({
name: z.string(),
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
models: z.array(z.string()).optional(),
requiresCustomNodes: z.array(z.string()).optional(),
thumbnailVariant: z.string().optional(),
mediaType: z.string().optional(),
mediaSubtype: z.string().optional(),
size: z.number().optional(),
vram: z.number().optional(),
openSource: z.boolean().optional(),
profile: zHubProfileSummary.optional(),
tutorialUrl: z.string().optional(),
logos: z.array(z.record(z.unknown())).optional(),
date: z.string().optional(),
io: z
.object({
inputs: z.array(z.record(z.unknown())).optional(),
outputs: z.array(z.record(z.unknown())).optional()
})
.optional(),
includeOnDistributions: z.array(z.string()).optional(),
thumbnailUrl: z.string().optional(),
thumbnailComparisonUrl: z.string().optional(),
shareId: z.string().optional(),
extendedDescription: z.string().optional(),
metaDescription: z.string().optional(),
howToUse: z.array(z.string()).optional(),
suggestedUseCases: z.array(z.string()).optional(),
faqItems: z
.array(
z.object({
question: z.string(),
answer: z.string()
})
)
.optional(),
contentTemplate: z.string().optional()
})
export const zUpdateHubProfileRequest = z.object({
display_name: z.string().optional(),
description: z.string().optional(),
avatar_token: z.string().nullish(),
website_urls: z.array(z.string()).optional()
})
export const zCreateHubProfileRequest = z.object({
workspace_id: z.string(),
username: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
avatar_token: z.string().optional(),
website_urls: z.array(z.string()).optional()
})
export const zHubProfile = z.object({
username: z.string(),
display_name: z.string().optional(),
description: z.string().optional(),
avatar_url: z.string().optional(),
website_urls: z.array(z.string()).optional()
})
export const zImportPublishedAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string())
})
export const zPublishedWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
name: z.string(),
listed: z.boolean(),
publish_time: z.string().datetime().nullish(),
workflow_json: z.record(z.unknown()),
assets: z.array(zAssetInfo)
})
export const zWorkflowApiAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
export const zWorkflowApiAssetsRequest = z.object({
workflow_api_json: z.record(z.unknown())
})
export const zForkWorkflowRequest = z.object({
source_version: z.number().int(),
name: z.string().optional()
@@ -992,7 +1193,9 @@ export const zListAssetsData = z.object({
.enum(['name', 'created_at', 'updated_at', 'size', 'last_access_time'])
.optional(),
order: z.enum(['asc', 'desc']).optional(),
include_public: z.boolean().optional().default(true)
job_ids: z.array(z.string().uuid()).optional(),
include_public: z.boolean().optional().default(true),
asset_hash: z.string().optional()
})
.optional()
})
@@ -1234,6 +1437,28 @@ export const zCheckAssetByHashData = z.object({
query: z.never().optional()
})
export const zPostAssetsFromWorkflowData = z.object({
body: zWorkflowApiAssetsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zPostAssetsFromWorkflowResponse = zWorkflowApiAssetsResponse
export const zImportPublishedAssetsData = z.object({
body: zImportPublishedAssetsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Successfully imported assets
*/
export const zImportPublishedAssetsResponse2 = zImportPublishedAssetsResponse
export const zListSecretsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1633,6 +1858,13 @@ export const zCreateDeletionRequestData = z.object({
query: z.never().optional()
})
/**
* Created - deletion request created or already exists
*/
export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -1928,3 +2160,171 @@ export const zForkWorkflowData = z.object({
* Workflow forked successfully
*/
export const zForkWorkflowResponse = zWorkflowResponse
export const zCreateHubProfileData = z.object({
body: zCreateHubProfileRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Hub profile created
*/
export const zCreateHubProfileResponse = zHubProfile
export const zGetMyHubProfileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Hub profile
*/
export const zGetMyHubProfileResponse = zHubProfile
export const zCheckHubUsernameData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.object({
username: z.string()
})
})
/**
* Username availability result
*/
export const zCheckHubUsernameResponse = zHubUsernameCheckResponse
export const zGetHubProfileByUsernameData = z.object({
body: z.never().optional(),
path: z.object({
username: z.string()
}),
query: z.never().optional()
})
/**
* Hub profile
*/
export const zGetHubProfileByUsernameResponse = zHubProfile
export const zUpdateHubProfileData = z.object({
body: zUpdateHubProfileRequest,
path: z.object({
username: z.string()
}),
query: z.never().optional()
})
/**
* Hub profile updated
*/
export const zUpdateHubProfileResponse = zHubProfile
export const zCreateHubAssetUploadUrlData = z.object({
body: zHubAssetUploadUrlRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Presigned upload URL and token
*/
export const zCreateHubAssetUploadUrlResponse = zHubAssetUploadUrlResponse
export const zListHubLabelsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
type: z.enum(['tag', 'model', 'custom_node']).optional()
})
.optional()
})
/**
* List of labels
*/
export const zListHubLabelsResponse = zHubLabelListResponse
export const zListHubWorkflowsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
cursor: z.string().optional(),
limit: z.number().int().gte(1).lte(100).optional().default(20),
search: z.string().optional(),
tag: z.string().optional(),
username: z.string().optional(),
detail: z.boolean().optional().default(false)
})
.optional()
})
/**
* Paginated list of hub workflows
*/
export const zListHubWorkflowsResponse = zHubWorkflowListResponse
export const zPublishHubWorkflowData = z.object({
body: zPublishHubWorkflowRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Workflow published to hub
*/
export const zPublishHubWorkflowResponse = zHubWorkflowDetail
export const zListHubWorkflowIndexData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* List of hub workflow template entries
*/
export const zListHubWorkflowIndexResponse = z.array(zHubWorkflowTemplateEntry)
export const zDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully unpublished
*/
export const zDeleteHubWorkflowResponse = z.void()
export const zGetHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Hub workflow detail
*/
export const zGetHubWorkflowResponse = zHubWorkflowDetail
export const zGetPublishedWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Published workflow details with asset statuses
*/
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail

1665
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@astrojs/vue': ^5.0.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
@@ -50,6 +51,7 @@ catalog:
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
@@ -58,6 +60,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4

View File

@@ -22,6 +22,7 @@ interface PerfMeasurement {
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
heapUsedBytes: number
domNodes: number
jsHeapTotalBytes: number
scriptDurationMs: number
@@ -43,22 +44,46 @@ const HISTORY_DIR = 'temp/perf-history'
type MetricKey =
| 'styleRecalcs'
| 'styleRecalcDurationMs'
| 'layouts'
| 'layoutDurationMs'
| 'taskDurationMs'
| 'domNodes'
| 'scriptDurationMs'
| 'eventListeners'
| 'totalBlockingTimeMs'
| 'frameDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
| 'heapUsedBytes'
interface MetricDef {
key: MetricKey
label: string
unit: string
/** Minimum absolute delta to consider meaningful (effect size gate) */
minAbsDelta?: number
}
const REPORTED_METRICS: MetricDef[] = [
{ key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' },
{
key: 'styleRecalcDurationMs',
label: 'style recalc duration',
unit: 'ms'
},
{ key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 },
{
key: 'styleRecalcs',
label: 'style recalc count',
unit: '',
minAbsDelta: 5
},
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' },
{ key: 'domNodes', label: 'DOM nodes', unit: '' },
{ key: 'scriptDurationMs', label: 'script duration', unit: 'ms' },
{ key: 'eventListeners', label: 'event listeners', unit: '' },
{ key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' },
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }
{ key: 'frameDurationMs', label: 'frame duration', unit: 'ms' },
{ key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' },
{ key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 },
{ key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 }
]
function groupByName(
@@ -134,7 +159,9 @@ function computeCV(stats: MetricStats): number {
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
if (unit === 'ms') return `${value.toFixed(0)}ms`
if (unit === 'bytes') return formatBytes(value)
return `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
@@ -159,6 +186,21 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null {
return values.reduce((sum, v) => sum + v, 0) / values.length
}
function medianMetric(
samples: PerfMeasurement[],
key: MetricKey
): number | null {
const values = samples
.map((s) => getMetricValue(s, key))
.filter((v): v is number => v !== null)
.sort((a, b) => a - b)
if (values.length === 0) return null
const mid = Math.floor(values.length / 2)
return values.length % 2 === 0
? (values[mid - 1] + values[mid]) / 2
: values[mid]
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -173,7 +215,7 @@ function renderFullReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'| Metric | Baseline | PR (median) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
@@ -183,36 +225,38 @@ function renderFullReport(
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) {
// Use median for PR values — robust to outlier runs in CI
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const baseVal = medianMetric(baseSamples, key)
if (baseVal === null) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |`
)
continue
}
const absDelta = prVal - baseVal
const deltaPct =
baseVal === 0
? prMean === 0
? prVal === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
: ((prVal - baseVal) / baseVal) * 100
const z = zScore(prVal, histStats)
const sig = classifyChange(z, cv, absDelta, minAbsDelta)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
@@ -299,7 +343,7 @@ function renderColdStartReport(
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
`> Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
@@ -309,31 +353,31 @@ function renderColdStartReport(
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const baseVal = medianMetric(baseSamples, key)
if (baseVal === null) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
`| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |`
)
continue
}
const deltaPct =
baseVal === 0
? prMean === 0
? prVal === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
: ((prVal - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
@@ -352,14 +396,10 @@ function renderNoBaselineReport(
)
for (const [testName, prSamples] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const prMean = meanMetric(prSamples, key)
if (prMean === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`)
const prVal = medianMetric(prSamples, key)
if (prVal === null) continue
lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`)
}
const heapMean =
prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) /
prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
}

View File

@@ -99,6 +99,21 @@ describe('classifyChange', () => {
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
it('returns neutral when absDelta below minAbsDelta despite high z', () => {
// z=7.2 but only 1 unit change with minAbsDelta=5
expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral')
expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral')
})
it('returns regression when absDelta meets minAbsDelta', () => {
expect(classifyChange(3, 10, 10, 5)).toBe('regression')
})
it('ignores effect size gate when minAbsDelta not provided', () => {
expect(classifyChange(3, 10)).toBe('regression')
expect(classifyChange(3, 10, 1)).toBe('regression')
})
})
describe('formatSignificance', () => {

View File

@@ -31,12 +31,28 @@ export function zScore(value: number, stats: MetricStats): number | null {
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
/**
* Classify a metric change as regression/improvement/neutral/noisy.
*
* Uses both statistical significance (z-score) and practical significance
* (effect size gate via minAbsDelta) to reduce false positives from
* integer-quantized metrics with near-zero variance.
*/
export function classifyChange(
z: number | null,
historicalCV: number
historicalCV: number,
absDelta?: number,
minAbsDelta?: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
// Effect size gate: require minimum absolute change for count metrics
// to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression.
if (minAbsDelta !== undefined && absDelta !== undefined) {
if (Math.abs(absDelta) < minAbsDelta) return 'neutral'
}
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'

View File

@@ -3167,6 +3167,7 @@
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
@@ -3174,12 +3175,6 @@
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
"workflowDescription": "Workflow description",
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
"workflowType": "Workflow type",
"workflowTypePlaceholder": "Select the type",
"workflowTypeImageGeneration": "Image generation",
"workflowTypeVideoGeneration": "Video generation",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeEditing": "Editing",
"tags": "Tags",
"tagsDescription": "Select tags so people can find your workflow faster",
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
@@ -3206,11 +3201,17 @@
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"removeExampleImage": "Remove example image",
"exampleImage": "Example image {index}",
"exampleImagePosition": "Example image {index} of {total}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"shareAs": "Share as",
"additionalInfo": "Additional information",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile"
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
},
"comfyHubProfile": {
"checkingAccess": "Checking your publishing access...",
@@ -3229,6 +3230,7 @@
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"usernameError": "342 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",

View File

@@ -24,7 +24,13 @@ vi.mock('@/platform/telemetry/topupTracker', () => ({
const hoisted = vi.hoisted(() => ({
mockNodeDefsByName: {} as Record<string, unknown>,
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
mockActiveWorkflow: null as null | {
filename: string
fullFilename: string
},
mockKnownTemplateNames: new Set<string>(),
mockTemplateByName: null as null | { sourceModule?: string }
}))
vi.mock('@/stores/nodeDefStore', () => ({
@@ -35,7 +41,9 @@ vi.mock('@/stores/nodeDefStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
get activeWorkflow() {
return hoisted.mockActiveWorkflow
}
})
}))
@@ -43,7 +51,11 @@ vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
knownTemplateNames: new Set()
get knownTemplateNames() {
return hoisted.mockKnownTemplateNames
},
getTemplateByName: (_name: string) => hoisted.mockTemplateByName,
getEnglishMetadata: () => null
})
})
)
@@ -85,6 +97,9 @@ describe('getExecutionContext', () => {
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
delete hoisted.mockNodeDefsByName[key]
}
hoisted.mockActiveWorkflow = null
hoisted.mockKnownTemplateNames = new Set()
hoisted.mockTemplateByName = null
})
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
@@ -175,4 +190,50 @@ describe('getExecutionContext', () => {
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
})
describe('template detection', () => {
it('detects a regular template by name', () => {
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
hoisted.mockTemplateByName = { sourceModule: 'default' }
hoisted.mockActiveWorkflow = {
filename: 'flux-dev',
fullFilename: 'flux-dev.json'
}
const context = getExecutionContext()
expect(context.is_template).toBe(true)
expect(context.workflow_name).toBe('flux-dev')
})
it('detects an app mode template whose name ends with .app', () => {
hoisted.mockKnownTemplateNames = new Set([
'templates-qwen_multiangle.app'
])
hoisted.mockTemplateByName = { sourceModule: 'default' }
// getFilenameDetails strips ".app.json" as a compound extension, yielding
// filename = "templates-qwen_multiangle" — the previous code would fail here.
hoisted.mockActiveWorkflow = {
filename: 'templates-qwen_multiangle',
fullFilename: 'templates-qwen_multiangle.app.json'
}
const context = getExecutionContext()
expect(context.is_template).toBe(true)
expect(context.workflow_name).toBe('templates-qwen_multiangle.app')
})
it('does not flag a non-template workflow as a template', () => {
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
hoisted.mockActiveWorkflow = {
filename: 'my-custom-workflow',
fullFilename: 'my-custom-workflow.json'
}
const context = getExecutionContext()
expect(context.is_template).toBe(false)
})
})
})

View File

@@ -80,20 +80,21 @@ export function getExecutionContext(): ExecutionContext {
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
// Use fullFilename minus .json to reconstruct the template name, which
// preserves compound suffixes like ".app" (e.g. "foo.app.json" → "foo.app").
// Using just `filename` strips ".app.json" entirely (e.g. "foo"), which
// won't match knownTemplateNames entries like "foo.app".
const templateName = activeWorkflow.fullFilename.replace(/\.json$/i, '')
const isTemplate = templatesStore.knownTemplateNames.has(templateName)
if (isTemplate) {
const template = templatesStore.getTemplateByName(activeWorkflow.filename)
const template = templatesStore.getTemplateByName(templateName)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(templateName)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
workflow_name: templateName,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,

View File

@@ -133,9 +133,45 @@
role="tabpanel"
aria-labelledby="tab-publish"
data-testid="publish-tab-panel"
class="min-h-0"
class="flex min-h-0 flex-col gap-4"
>
<template v-if="dialogState === 'loading'">
<Skeleton class="h-3 w-4/5" />
<Skeleton class="h-3 w-3/5" />
<Skeleton class="h-10 w-full" />
</template>
<template v-else-if="dialogState === 'unsaved'">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('comfyHubPublish.unsavedDescription') }}
</p>
<label v-if="isTemporary" class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted-foreground">
{{ $t('shareWorkflow.workflowNameLabel') }}
</span>
<Input
ref="publishNameInputRef"
v-model="workflowName"
:disabled="isSaving"
@keydown.enter="() => handleSave()"
/>
</label>
<Button
variant="primary"
size="lg"
:loading="isSaving"
@click="() => handleSave()"
>
{{
isSaving
? $t('shareWorkflow.saving')
: $t('shareWorkflow.saveButton')
}}
</Button>
</template>
<ComfyHubPublishIntroPanel
v-else
data-testid="publish-intro"
:on-create-profile="handleOpenPublishDialog"
:on-close="onClose"
@@ -215,10 +251,15 @@ const dialogMode = ref<DialogMode>('shareLink')
const acknowledged = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
const publishNameInputRef = ref<InstanceType<typeof Input> | null>(null)
function focusNameInput() {
nameInputRef.value?.focus()
nameInputRef.value?.select()
function focusActiveNameInput() {
const input =
dialogMode.value === 'publishToHub'
? publishNameInputRef.value
: nameInputRef.value
input?.focus()
input?.select()
}
const isTemporary = computed(
@@ -228,7 +269,7 @@ const isTemporary = computed(
watch(dialogState, async (state) => {
if (state === 'unsaved' && isTemporary.value) {
await nextTick()
focusNameInput()
focusActiveNameInput()
}
})
@@ -255,10 +296,14 @@ function tabButtonClass(mode: DialogMode) {
)
}
function handleDialogModeChange(nextMode: DialogMode) {
async function handleDialogModeChange(nextMode: DialogMode) {
if (nextMode === dialogMode.value) return
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
dialogMode.value = nextMode
if (dialogState.value === 'unsaved' && isTemporary.value) {
await nextTick()
focusActiveNameInput()
}
}
watch(showPublishToHubTab, (isVisible) => {

View File

@@ -75,8 +75,23 @@
>
@
</span>
<Input id="profile-username" v-model="username" class="pl-7" />
<Input
id="profile-username"
v-model="username"
class="pl-7"
:aria-invalid="showUsernameError ? 'true' : 'false'"
:aria-describedby="
showUsernameError ? 'profile-username-error' : undefined
"
/>
</div>
<p
v-if="showUsernameError"
id="profile-username-error"
class="text-xs text-destructive-background"
>
{{ $t('comfyHubProfile.usernameError') }}
</p>
</div>
<div class="flex flex-col gap-2">
@@ -105,7 +120,7 @@
<Button
variant="primary"
size="lg"
:disabled="!username.trim() || isCreating"
:disabled="!isUsernameValid || isCreating"
@click="handleCreate"
>
{{
@@ -156,6 +171,16 @@ const profilePictureFile = ref<File | null>(null)
const profilePreviewUrl = useObjectUrl(profilePictureFile)
const isCreating = ref(false)
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
const isUsernameValid = computed(() =>
VALID_USERNAME_PATTERN.test(username.value)
)
const showUsernameError = computed(
() => username.value.length > 0 && !isUsernameValid.value
)
const profileInitial = computed(() => {
const source = name.value.trim() || username.value.trim()
return source ? source[0].toUpperCase() : 'C'

View File

@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
function mountStep(
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
) {
return mount(ComfyHubDescribeStep, {
props: {
name: 'Workflow Name',
description: 'Workflow description',
tags: [],
...props
},
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
Input: {
template:
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
Textarea: {
template:
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
TagsInput: {
template:
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
props: {
modelValue: {
type: Array,
default: () => []
},
disabled: Boolean
}
},
TagsInputItem: {
template:
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
props: ['value']
},
TagsInputItemText: {
template: '<span data-testid="tag-item-text" />'
},
TagsInputItemDelete: {
template: '<button data-testid="tag-item-delete" type="button" />'
},
TagsInputInput: {
template: '<input data-testid="tags-input-input" />'
},
Button: {
template:
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
}
}
}
})
}
describe('ComfyHubDescribeStep', () => {
it('emits name and description updates', async () => {
const wrapper = mountStep()
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
await wrapper
.find('[data-testid="description-input"]')
.setValue('New description')
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
expect(wrapper.emitted('update:description')).toEqual([['New description']])
})
it('adds a suggested tag when clicked', async () => {
const wrapper = mountStep()
const suggestionButtons = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(suggestionButtons.length).toBeGreaterThan(0)
const firstSuggestion = suggestionButtons[0].attributes('data-value')
await suggestionButtons[0].trigger('click')
const tagUpdates = wrapper.emitted('update:tags')
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
})
it('hides already-selected tags from suggestions', () => {
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
const wrapper = mountStep({ tags: [selectedTag] })
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).not.toContain(selectedTag)
})
it('toggles between default and full suggestion lists', async () => {
const wrapper = mountStep()
const defaultSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(defaultSuggestions).toHaveLength(10)
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
await wrapper.vm.$nextTick()
const allSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
})
})

View File

@@ -25,35 +25,8 @@
<label class="flex flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.workflowType') }}
</span>
<Select
:model-value="workflowType"
@update:model-value="
emit('update:workflowType', $event as ComfyHubWorkflowType)
"
>
<SelectTrigger>
<SelectValue
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in workflowTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</label>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.tagsDescription') }}
</legend>
</span>
<TagsInput
v-slot="{ isEmpty }"
always-editing
@@ -67,54 +40,48 @@
</TagsInputItem>
<TagsInputInput :is-empty />
</TagsInput>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
</label>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</fieldset>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
const { tags, workflowType } = defineProps<{
const { tags } = defineProps<{
name: string
description: string
workflowType: ComfyHubWorkflowType | ''
tags: string[]
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:workflowType': [value: ComfyHubWorkflowType | '']
'update:tags': [value: string[]]
}>()
const { t } = useI18n()
const workflowTypeOptions = computed(() => [
{
value: 'imageGeneration',
label: t('comfyHubPublish.workflowTypeImageGeneration')
},
{
value: 'videoGeneration',
label: t('comfyHubPublish.workflowTypeVideoGeneration')
},
{
value: 'upscaling',
label: t('comfyHubPublish.workflowTypeUpscaling')
},
{
value: 'editing',
label: t('comfyHubPublish.workflowTypeEditing')
}
])
const INITIAL_TAG_SUGGESTION_COUNT = 10
const showAllSuggestions = ref(false)

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
draggable: vi.fn(() => vi.fn()),
dropTargetForElements: vi.fn(() => vi.fn()),
monitorForElements: vi.fn(() => vi.fn())
}))
function createImages(count: number): ExampleImage[] {
return Array.from({ length: count }, (_, i) => ({
id: `img-${i}`,
url: `blob:http://localhost/img-${i}`
}))
}
function mountStep(images: ExampleImage[]) {
return mount(ComfyHubExamplesStep, {
props: { exampleImages: images },
global: {
mocks: { $t: (key: string) => key }
}
})
}
describe('ComfyHubExamplesStep', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders all example images', () => {
const wrapper = mountStep(createImages(3))
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
})
it('emits reordered array when moving image left via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
})
it('emits reordered array when moving image right via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
})
it('does not emit when moving first image left (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[0].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('does not emit when moving last image right (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[2].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('emits filtered array when removing an image', async () => {
const wrapper = mountStep(createImages(2))
const removeBtn = wrapper.find(
'button[aria-label="comfyHubPublish.removeExampleImage"]'
)
expect(removeBtn.exists()).toBe(true)
await removeBtn.trigger('click')
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toHaveLength(1)
})
})

View File

@@ -1,21 +1,25 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<p class="text-sm">
<div class="flex min-h-0 flex-1 flex-col">
<p class="text-sm select-none">
{{
$t('comfyHubPublish.examplesDescription', {
selected: selectedExampleIds.length,
total: MAX_EXAMPLES
})
}}
</p>
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
<!-- Upload tile -->
<div
role="list"
class="group/grid grid gap-2"
style="grid-template-columns: repeat(auto-fill, 8rem)"
>
<!-- Upload tile (hidden when max images reached) -->
<label
v-if="showUploadTile"
tabindex="0"
role="button"
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@@ -40,83 +44,100 @@
}}</span>
</label>
<!-- Example images -->
<Button
<!-- Example images (drag to reorder) -->
<ReorderableExampleImage
v-for="(image, index) in exampleImages"
:key="image.id"
variant="textonly"
size="unset"
:class="
cn(
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
)
"
@click="toggleSelection(image.id)"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="size-full object-cover"
/>
<div
v-if="isSelected(image.id)"
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
>
{{ selectionIndex(image.id) }}
</div>
</Button>
:image="image"
:index="index"
:total="exampleImages.length"
:instance-id="instanceId"
@remove="removeImage"
@move="moveImage"
@insert-files="insertImagesAt"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import {
isFileTooLarge,
MAX_IMAGE_SIZE_MB
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@/utils/tailwindUtil'
import ReorderableExampleImage from './ReorderableExampleImage.vue'
const fileInputRef = ref<HTMLInputElement | null>(null)
const MAX_EXAMPLES = 8
const { exampleImages, selectedExampleIds } = defineProps<{
exampleImages: ExampleImage[]
selectedExampleIds: string[]
}>()
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
required: true
})
const emit = defineEmits<{
'update:exampleImages': [value: ExampleImage[]]
'update:selectedExampleIds': [value: string[]]
}>()
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
function isSelected(id: string): boolean {
return selectedExampleIds.includes(id)
const instanceId = Symbol('example-images')
let cleanupMonitor = () => {}
onMounted(() => {
cleanupMonitor = monitorForElements({
canMonitor: ({ source }) => source.data.instanceId === instanceId,
onDrop: ({ source, location }) => {
const destination = location.current.dropTargets[0]
if (!destination) return
const fromId = source.data.imageId
const toId = destination.data.imageId
if (typeof fromId !== 'string' || typeof toId !== 'string') return
reorderImages(fromId, toId)
}
})
})
onBeforeUnmount(() => {
cleanupMonitor()
})
function moveByIndex(fromIndex: number, toIndex: number) {
if (fromIndex < 0 || toIndex < 0) return
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
const updated = [...exampleImages.value]
const [moved] = updated.splice(fromIndex, 1)
updated.splice(toIndex, 0, moved)
exampleImages.value = updated
}
function selectionIndex(id: string): number {
return selectedExampleIds.indexOf(id) + 1
function reorderImages(fromId: string, toId: string) {
moveByIndex(
exampleImages.value.findIndex((img) => img.id === fromId),
exampleImages.value.findIndex((img) => img.id === toId)
)
}
function toggleSelection(id: string) {
if (isSelected(id)) {
emit(
'update:selectedExampleIds',
selectedExampleIds.filter((sid) => sid !== id)
)
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
emit('update:selectedExampleIds', [...selectedExampleIds, id])
function moveImage(id: string, direction: number) {
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
moveByIndex(currentIndex, currentIndex + direction)
}
function removeImage(id: string) {
const image = exampleImages.value.find((img) => img.id === id)
if (image?.file) {
URL.revokeObjectURL(image.url)
}
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
}
function addImages(files: FileList) {
const newImages: ExampleImage[] = Array.from(files)
function createExampleImages(files: FileList): ExampleImage[] {
return Array.from(files)
.filter((f) => f.type.startsWith('image/'))
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
.map((file) => ({
@@ -124,10 +145,51 @@ function addImages(files: FileList) {
url: URL.createObjectURL(file),
file
}))
}
if (newImages.length > 0) {
emit('update:exampleImages', [...exampleImages, ...newImages])
function addImages(files: FileList) {
const remaining = MAX_EXAMPLES - exampleImages.value.length
if (remaining <= 0) return
const created = createExampleImages(files)
const newImages = created.slice(0, remaining)
for (const img of created.slice(remaining)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length > 0) {
exampleImages.value = [...newImages, ...exampleImages.value]
}
}
function insertImagesAt(index: number, files: FileList) {
const created = createExampleImages(files)
if (created.length === 0) return
const updated = [...exampleImages.value]
const safeIndex = Math.min(Math.max(index, 0), updated.length)
const remaining = MAX_EXAMPLES - exampleImages.value.length
const maxInsert =
remaining <= 0 ? Math.max(updated.length - safeIndex, 0) : remaining
const newImages = created.slice(0, maxInsert)
for (const img of created.slice(maxInsert)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length === 0) return
if (remaining <= 0) {
const replacedImages = updated.splice(
safeIndex,
newImages.length,
...newImages
)
for (const img of replacedImages) {
if (img.file) URL.revokeObjectURL(img.url)
}
} else {
updated.splice(safeIndex, 0, ...newImages)
}
exampleImages.value = updated
}
function handleFileSelect(event: Event) {

View File

@@ -0,0 +1,96 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
<section class="flex flex-col gap-4">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.shareAs') }}
</span>
<div
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
>
<div
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
>
<img
v-if="profile.profilePictureUrl"
:src="profile.profilePictureUrl"
:alt="profile.username"
class="size-full rounded-full object-cover"
/>
<span v-else class="text-base text-white">
{{ (profile.name ?? profile.username).charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex flex-1 flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ profile.name ?? profile.username }}
</span>
<span class="text-sm text-muted-foreground">
@{{ profile.username }}
</span>
</div>
</div>
</section>
<section
v-if="isLoadingAssets || hasPrivateAssets"
class="flex flex-col gap-4"
>
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.additionalInfo') }}
</span>
<p
v-if="isLoadingAssets"
class="m-0 text-sm text-muted-foreground italic"
>
{{ $t('shareWorkflow.checkingAssets') }}
</p>
<ShareAssetWarningBox
v-else
v-model:acknowledged="acknowledged"
:items="privateAssets"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, watch } from 'vue'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
const { profile } = defineProps<{
profile: ComfyHubProfile
}>()
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
const ready = defineModel<boolean>('ready', { default: false })
const shareService = useWorkflowShareService()
const {
state: privateAssets,
isLoading: isLoadingAssets,
error: privateAssetsError
} = useAsyncState(() => shareService.getShareableAssets(), [])
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
const isReady = computed(
() =>
!isLoadingAssets.value &&
!privateAssetsError.value &&
(!hasPrivateAssets.value || acknowledged.value)
)
watch(
isReady,
(val) => {
ready.value = val
},
{ immediate: true }
)
</script>

View File

@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
const mockFetchProfile = vi.hoisted(() => vi.fn())
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
const mockApplyPrefill = vi.hoisted(() => vi.fn())
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -28,14 +45,16 @@ vi.mock(
formData: ref({
name: '',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}),
isFirstStep: ref(false),
isLastStep: ref(true),
@@ -43,17 +62,64 @@ vi.mock(
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
})
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
() => ({
useComfyHubPublishSubmission: () => ({
submitToComfyHub: mockSubmitToComfyHub
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getPublishStatus: mockGetPublishStatus
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: vi.fn(),
saveWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
},
saveWorkflow: vi.fn()
})
}))
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null,
prefill: null
})
})
function createWrapper() {
@@ -78,14 +144,16 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'isPublishing',
'onGoNext',
'onGoBack',
'onPublish',
'onRequireProfile',
'onGateComplete',
'onGateClose'
@@ -136,4 +204,72 @@ describe('ComfyHubPublishDialog', () => {
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('closes dialog after successful publish', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="publish"]').trigger('click')
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('applies prefill when workflow is already published with metadata', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: {
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
}
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith({
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
})
})
it('does not apply prefill when workflow is not published', async () => {
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('does not apply prefill when status has no prefill data', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: null
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('silently ignores prefill fetch errors', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -12,44 +12,106 @@
</template>
<template #leftPanel>
<ComfyHubPublishNav :current-step @step-click="goToStep" />
<ComfyHubPublishNav
v-if="!needsSave"
:current-step
@step-click="goToStep"
/>
</template>
<template #header />
<template #content>
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('comfyHubPublish.unsavedDescription') }}
</p>
<label v-if="isTemporary" class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted-foreground">
{{ $t('shareWorkflow.workflowNameLabel') }}
</span>
<Input
ref="nameInputRef"
v-model="workflowName"
:disabled="isSaving"
@keydown.enter="() => handleSave()"
/>
</label>
<Button
variant="primary"
size="lg"
:loading="isSaving"
@click="() => handleSave()"
>
{{
isSaving
? $t('shareWorkflow.saving')
: $t('shareWorkflow.saveButton')
}}
</Button>
</div>
<ComfyHubPublishWizardContent
v-else
:current-step
:form-data
:is-first-step
:is-last-step
:is-publishing
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
:on-require-profile="handleRequireProfile"
:on-gate-complete="handlePublishGateComplete"
:on-gate-close="handlePublishGateClose"
:on-publish="onClose"
:on-publish="handlePublish"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
import {
cachePublishPrefill,
getCachedPrefill,
useComfyHubPublishWizard
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { OnCloseKey } from '@/types/widgetTypes'
const { onClose } = defineProps<{
onClose: () => void
}>()
const { t } = useI18n()
const toast = useToast()
const { fetchProfile } = useComfyHubProfileGate()
const { submitToComfyHub } = useComfyHubPublishSubmission()
const shareService = useWorkflowShareService()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const {
currentStep,
formData,
@@ -59,8 +121,72 @@ const {
goNext,
goBack,
openProfileCreationStep,
closeProfileCreationStep
closeProfileCreationStep,
applyPrefill
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
const needsSave = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
const isTemporary = computed(
() => workflowStore.activeWorkflow?.isTemporary ?? false
)
function checkNeedsSave() {
const workflow = workflowStore.activeWorkflow
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
if (workflow) {
workflowName.value = workflow.filename.replace(/\.json$/i, '')
}
}
watch(needsSave, async (needs) => {
if (needs && isTemporary.value) {
await nextTick()
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
})
function buildWorkflowPath(directory: string, filename: string): string {
const normalizedDirectory = directory.replace(/\/+$/, '')
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
return normalizedDirectory
? `${normalizedDirectory}/${normalizedFilename}`
: normalizedFilename
}
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
async () => {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
if (workflow.isTemporary) {
const name = workflowName.value.trim()
if (!name) return
const newPath = buildWorkflowPath(workflow.directory, name)
await workflowService.renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
await workflowService.saveWorkflow(workflow)
}
checkNeedsSave()
},
undefined,
{
immediate: false,
onError: (error) => {
console.error('Failed to save workflow:', error)
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription')
})
}
}
)
function handlePublishGateComplete() {
closeProfileCreationStep()
@@ -75,18 +201,67 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
}
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
const path = workflowStore.activeWorkflow?.path
if (path) {
cachePublishPrefill(path, formData.value)
}
onClose()
} catch (error) {
console.error('Failed to publish workflow:', error)
toast.add({
severity: 'error',
summary: t('comfyHubPublish.publishFailedTitle'),
detail: t('comfyHubPublish.publishFailedDescription')
})
} finally {
isPublishing.value = false
}
}
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
formData.value = { ...formData.value, ...patch }
}
async function fetchPublishPrefill() {
const path = workflowStore.activeWorkflow?.path
if (!path) return
try {
const status = await shareService.getPublishStatus(path)
const prefill = status.isPublished
? (status.prefill ?? getCachedPrefill(path))
: getCachedPrefill(path)
if (prefill) {
applyPrefill(prefill)
}
} catch (error) {
console.warn('Failed to fetch publish prefill:', error)
const cached = getCachedPrefill(path)
if (cached) {
applyPrefill(cached)
}
}
}
onMounted(() => {
// Prefetch profile data in the background so finish-step profile context is ready.
checkNeedsSave()
void fetchProfile()
void fetchPublishPrefill()
})
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
URL.revokeObjectURL(image.url)
if (image.file) {
URL.revokeObjectURL(image.url)
}
}
})

View File

@@ -1,26 +1,30 @@
<template>
<footer class="flex shrink items-center justify-between py-2">
<div>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
</div>
<div class="flex gap-4">
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</div>
<footer
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
<Button
v-if="!isLastStep"
variant="primary"
size="lg"
@click="$emit('next')"
>
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled || isPublishing"
:loading="isPublishing"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</footer>
</template>
@@ -31,6 +35,7 @@ defineProps<{
isFirstStep: boolean
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
}>()
defineEmits<{

View File

@@ -1,6 +1,6 @@
<template>
<nav class="flex flex-col gap-6 px-3 py-4">
<ol class="flex flex-col">
<ol class="flex list-none flex-col p-0">
<li
v-for="step in steps"
:key="step.name"

View File

@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
const mockCheckProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockHasProfile = ref<boolean | null>(true)
const mockIsFetchingProfile = ref(false)
const mockProfile = ref<{ username: string; name?: string } | null>({
username: 'testuser',
name: 'Test User'
})
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
checkProfile: mockCheckProfile,
hasProfile: mockHasProfile
hasProfile: mockHasProfile,
isFetchingProfile: mockIsFetchingProfile,
profile: mockProfile
})
})
)
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
return {
name: 'Test Workflow',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}
}
@@ -61,8 +70,11 @@ describe('ComfyHubPublishWizardContent', () => {
beforeEach(() => {
vi.clearAllMocks()
onPublish.mockResolvedValue(undefined)
mockCheckProfile.mockResolvedValue(true)
mockHasProfile.value = true
mockIsFetchingProfile.value = false
mockProfile.value = { username: 'testuser', name: 'Test User' }
mockFlags.comfyHubProfileGateEnabled = true
})
@@ -99,9 +111,23 @@ describe('ComfyHubPublishWizardContent', () => {
template: '<div data-testid="publish-gate-flow" />',
props: ['onProfileCreated', 'onClose', 'showCloseButton']
},
Skeleton: {
template: '<div class="skeleton" />'
},
ComfyHubDescribeStep: {
template: '<div data-testid="describe-step" />'
},
ComfyHubFinishStep: {
template: '<div data-testid="finish-step" />',
props: ['profile', 'acknowledged', 'ready'],
emits: ['update:ready', 'update:acknowledged'],
setup(
_: unknown,
{ emit }: { emit: (e: string, v: boolean) => void }
) {
emit('update:ready', true)
}
},
ComfyHubExamplesStep: {
template: '<div data-testid="examples-step" />'
},
@@ -115,8 +141,13 @@ describe('ComfyHubPublishWizardContent', () => {
},
ComfyHubPublishFooter: {
template:
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: [
'isFirstStep',
'isLastStep',
'isPublishDisabled',
'isPublishing'
],
emits: ['publish', 'next', 'back']
}
}
@@ -124,43 +155,19 @@ describe('ComfyHubPublishWizardContent', () => {
})
}
describe('handlePublish — double-click guard', () => {
it('prevents concurrent publish calls', async () => {
let resolveCheck!: (v: boolean) => void
mockCheckProfile.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCheck = resolve
})
)
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
let reject: (error: unknown) => void = () => {}
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
resolveCheck(true)
await flushPromises()
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
expect(onPublish).toHaveBeenCalledTimes(1)
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
})
describe('handlePublish — feature flag bypass', () => {
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
return { promise, resolve, reject }
}
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish — profile check routing', () => {
describe('handlePublish - profile check routing', () => {
it('calls onPublish when profile exists', async () => {
mockCheckProfile.mockResolvedValue(true)
@@ -197,20 +204,83 @@ describe('ComfyHubPublishWizardContent', () => {
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('resets guard after checkProfile error so retry is possible', async () => {
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish - async submission', () => {
it('prevents duplicate publish submissions while in-flight', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(1)
publishDeferred.resolve(undefined)
await flushPromises()
})
it('calls onPublish and does not close when publish request fails', async () => {
const publishError = new Error('Publish failed')
onPublish.mockRejectedValueOnce(publishError)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
expect(onGateClose).not.toHaveBeenCalled()
})
it('shows publish disabled while submitting', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).not.toHaveBeenCalled()
mockCheckProfile.mockResolvedValue(true)
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('true')
expect(footer.attributes('data-is-publishing')).toBe('true')
publishDeferred.resolve(undefined)
await flushPromises()
expect(footer.attributes('data-is-publishing')).toBe('false')
})
it('resets guard after publish error so retry is possible', async () => {
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
onPublish.mockResolvedValueOnce(undefined)
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(2)
})
})
@@ -223,9 +293,10 @@ describe('ComfyHubPublishWizardContent', () => {
expect(footer.attributes('data-publish-disabled')).toBe('true')
})
it('enables publish when gate enabled and hasProfile is true', () => {
it('enables publish when gate enabled and hasProfile is true', async () => {
mockHasProfile.value = true
const wrapper = createWrapper()
await flushPromises()
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('false')

View File

@@ -7,17 +7,15 @@
:on-close="onGateClose"
:show-close-button="false"
/>
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ComfyHubDescribeStep
v-if="currentStep === 'describe'"
:name="formData.name"
:description="formData.description"
:workflow-type="formData.workflowType"
:tags="formData.tags"
@update:name="onUpdateFormData({ name: $event })"
@update:description="onUpdateFormData({ description: $event })"
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
@update:tags="onUpdateFormData({ tags: $event })"
/>
<div
@@ -37,13 +35,22 @@
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
:selected-example-ids="formData.selectedExampleIds"
@update:example-images="onUpdateFormData({ exampleImages: $event })"
@update:selected-example-ids="
onUpdateFormData({ selectedExampleIds: $event })
"
/>
</div>
<div
v-else-if="currentStep === 'finish' && isProfileLoading"
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
>
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-20 w-full rounded-2xl" />
</div>
<ComfyHubFinishStep
v-else-if="currentStep === 'finish' && hasProfile && profile"
v-model:ready="finishStepReady"
v-model:acknowledged="assetsAcknowledged"
:profile
/>
<ComfyHubProfilePromptPanel
v-else-if="currentStep === 'finish'"
@request-profile="onRequireProfile"
@@ -53,6 +60,7 @@
:is-first-step
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
@@ -70,8 +78,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
@@ -81,6 +91,7 @@ const {
formData,
isFirstStep,
isLastStep,
isPublishing = false,
onGoNext,
onGoBack,
onUpdateFormData,
@@ -93,10 +104,11 @@ const {
formData: ComfyHubPublishFormData
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
onPublish: () => void
onPublish: () => Promise<void>
onRequireProfile: () => void
onGateComplete?: () => void
onGateClose?: () => void
@@ -104,24 +116,42 @@ const {
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { checkProfile, hasProfile } = useComfyHubProfileGate()
const { checkProfile, hasProfile, isFetchingProfile, profile } =
useComfyHubProfileGate()
const isProfileLoading = computed(
() => hasProfile.value === null || isFetchingProfile.value
)
const finishStepReady = ref(false)
const assetsAcknowledged = ref(false)
const isResolvingPublishAccess = ref(false)
const isPublishInFlight = computed(
() => isPublishing || isResolvingPublishAccess.value
)
const isFinishStepVisible = computed(
() =>
currentStep === 'finish' &&
hasProfile.value === true &&
profile.value !== null
)
const isPublishDisabled = computed(
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
() =>
isPublishInFlight.value ||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
(isFinishStepVisible.value && !finishStepReady.value)
)
async function handlePublish() {
if (isResolvingPublishAccess.value) {
return
}
if (!flags.comfyHubProfileGateEnabled) {
onPublish()
if (isResolvingPublishAccess.value || isPublishing) {
return
}
isResolvingPublishAccess.value = true
try {
if (!flags.comfyHubProfileGateEnabled) {
await onPublish()
return
}
let profileExists: boolean
try {
profileExists = await checkProfile()
@@ -131,11 +161,13 @@ async function handlePublish() {
}
if (profileExists) {
onPublish()
await onPublish()
return
}
onRequireProfile()
} catch (error) {
toastErrorHandler(error)
} finally {
isResolvingPublishAccess.value = false
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
<div class="flex flex-col gap-2">
<span class="text-sm text-base-foreground select-none">
{{ $t('comfyHubPublish.selectAThumbnail') }}
</legend>
</span>
<ToggleGroup
type="single"
:model-value="thumbnailType"
@@ -14,18 +14,19 @@
v-for="option in thumbnailOptions"
:key="option.value"
:value="option.value"
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
>
<span class="text-center text-sm font-bold text-base-foreground">
<i :class="cn('size-4', option.icon)" />
<span class="text-center text-sm font-bold">
{{ option.label }}
</span>
</ToggleGroupItem>
</ToggleGroup>
</fieldset>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
<span class="text-sm text-base-foreground select-none">
{{ uploadSectionLabel }}
</span>
<Button
@@ -40,7 +41,7 @@
<template v-if="thumbnailType === 'imageComparison'">
<div
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
>
<div
v-if="hasBothComparisonImages"
@@ -69,7 +70,7 @@
<div
:class="
cn(
'col-span-full row-span-full flex gap-2',
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
hasBothComparisonImages && 'invisible'
)
"
@@ -80,8 +81,10 @@
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
:class="
cn(
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
comparisonPreviewUrls[slot.key]
? 'self-start'
: 'flex-[0_1_1]',
comparisonOverStates[slot.key]
? 'border-muted-foreground'
: 'border-border-default hover:border-muted-foreground'
@@ -123,7 +126,7 @@
ref="singleDropRef"
:class="
cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
isOverSingleDrop
? 'border-muted-foreground'
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
const thumbnailOptions = [
{
value: 'image' as const,
label: t('comfyHubPublish.thumbnailImage')
label: t('comfyHubPublish.thumbnailImage'),
icon: 'icon-[lucide--image]'
},
{
value: 'video' as const,
label: t('comfyHubPublish.thumbnailVideo')
label: t('comfyHubPublish.thumbnailVideo'),
icon: 'icon-[lucide--video]'
},
{
value: 'imageComparison' as const,
label: t('comfyHubPublish.thumbnailImageComparison')
label: t('comfyHubPublish.thumbnailImageComparison'),
icon: 'icon-[lucide--diff]'
}
]

View File

@@ -0,0 +1,161 @@
<template>
<div
ref="tileRef"
:class="
cn(
'group focus-visible:outline-ring relative aspect-square overflow-hidden rounded-sm outline-offset-2 focus-visible:outline-2',
state === 'dragging' && 'opacity-40',
state === 'over' && 'ring-2 ring-primary'
)
"
tabindex="0"
role="listitem"
:aria-label="
$t('comfyHubPublish.exampleImagePosition', {
index: index + 1,
total: total
})
"
@pointerdown="tileRef && focusVisible(tileRef)"
@keydown.left.prevent="handleArrowKey(-1, $event)"
@keydown.right.prevent="handleArrowKey(1, $event)"
@keydown.delete.prevent="handleRemove"
@keydown.backspace.prevent="handleRemove"
@dragover.prevent.stop
@drop.prevent.stop="handleFileDrop"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="pointer-events-none size-full object-cover"
draggable="false"
/>
<Button
variant="textonly"
size="icon"
:aria-label="$t('comfyHubPublish.removeExampleImage')"
tabindex="-1"
class="absolute top-1 right-1 flex size-6 items-center justify-center bg-black/60 text-white opacity-0 transition-opacity not-group-has-focus-visible/grid:group-hover:opacity-100 group-focus-visible:opacity-100 hover:bg-black/80"
@click="$emit('remove', image.id)"
>
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
</Button>
</div>
</template>
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { cn } from '@/utils/tailwindUtil'
const { image, index, total, instanceId } = defineProps<{
image: ExampleImage
index: number
total: number
instanceId: symbol
}>()
const emit = defineEmits<{
remove: [id: string]
move: [id: string, direction: number]
insertFiles: [index: number, files: FileList]
}>()
// focusVisible is a Chromium 122+ extension to FocusOptions
// (not yet in TypeScript's lib.dom.d.ts)
function focusVisible(el: HTMLElement) {
el.focus({ focusVisible: true } as FocusOptions)
}
async function handleArrowKey(direction: number, event: KeyboardEvent) {
if (event.shiftKey) {
emit('move', image.id, direction)
await nextTick()
if (tileRef.value) focusVisible(tileRef.value)
} else {
focusSibling(direction)
}
}
function focusSibling(direction: number) {
const sibling =
direction < 0
? tileRef.value?.previousElementSibling
: tileRef.value?.nextElementSibling
if (sibling instanceof HTMLElement) focusVisible(sibling)
}
async function handleRemove() {
const next =
tileRef.value?.nextElementSibling ?? tileRef.value?.previousElementSibling
emit('remove', image.id)
await nextTick()
if (next instanceof HTMLElement) focusVisible(next)
}
function handleFileDrop(event: DragEvent) {
if (event.dataTransfer?.files?.length) {
emit('insertFiles', index, event.dataTransfer.files)
}
}
const tileRef = ref<HTMLElement | null>(null)
type DragState = 'idle' | 'dragging' | 'over'
const state = ref<DragState>('idle')
const tileGetter = () => tileRef.value as HTMLElement
usePragmaticDraggable(tileGetter, {
getInitialData: () => ({
type: 'example-image',
imageId: image.id,
instanceId
}),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
nativeSetDragImage,
render: ({ container }) => {
const img = tileRef.value?.querySelector('img')
if (!img) return
const preview = img.cloneNode(true) as HTMLImageElement
preview.style.width = '8rem'
preview.style.height = '8rem'
preview.style.objectFit = 'cover'
preview.style.borderRadius = '4px'
container.appendChild(preview)
}
})
},
onDragStart: () => {
state.value = 'dragging'
},
onDrop: () => {
state.value = 'idle'
}
})
usePragmaticDroppable(tileGetter, {
getData: () => ({ imageId: image.id }),
canDrop: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === 'example-image' &&
source.data.imageId !== image.id,
onDragEnter: () => {
state.value = 'over'
},
onDragLeave: () => {
state.value = 'idle'
},
onDrop: () => {
state.value = 'idle'
}
})
</script>

View File

@@ -2,16 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGetMyProfile = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockCreateProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockResolvedUserInfo = vi.hoisted(() => ({
value: { id: 'user-a' }
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi
}
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
getMyProfile: mockGetMyProfile,
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
createProfile: mockCreateProfile
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
@@ -35,19 +41,16 @@ const mockProfile: ComfyHubProfile = {
description: 'A test profile'
}
function mockSuccessResponse(data?: unknown) {
return {
ok: true,
json: async () => data ?? mockProfile
} as Response
}
function mockErrorResponse(status = 500, message = 'Server error') {
return {
ok: false,
status,
json: async () => ({ message })
} as Response
function setCurrentWorkspace(workspaceId: string) {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({
id: workspaceId,
type: 'team',
name: 'Test Workspace',
role: 'owner'
})
)
}
describe('useComfyHubProfileGate', () => {
@@ -56,6 +59,15 @@ describe('useComfyHubProfileGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolvedUserInfo.value = { id: 'user-a' }
setCurrentWorkspace('workspace-1')
mockGetMyProfile.mockResolvedValue(mockProfile)
mockRequestAssetUploadUrl.mockResolvedValue({
uploadUrl: 'https://upload.example.com/avatar.png',
publicUrl: 'https://cdn.example.com/avatar.png',
token: 'avatar-token'
})
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockCreateProfile.mockResolvedValue(mockProfile)
// Reset module-level singleton refs
gate = useComfyHubProfileGate()
@@ -66,50 +78,40 @@ describe('useComfyHubProfileGate', () => {
})
describe('fetchProfile', () => {
it('returns mapped profile when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('fetches profile from /hub/profiles/me', async () => {
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
expect(mockGetMyProfile).toHaveBeenCalledOnce()
})
it('returns cached profile when already fetched', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('reuses cached profile state per user', async () => {
await gate.fetchProfile()
await gate.fetchProfile()
expect(mockGetMyProfile).toHaveBeenCalledTimes(1)
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.fetchProfile()
expect(mockGetMyProfile).toHaveBeenCalledTimes(2)
})
it('sets hasProfile to false when fetch throws', async () => {
mockGetMyProfile.mockRejectedValue(new Error('Network error'))
await gate.fetchProfile()
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches profile when force option is enabled', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.fetchProfile()
await gate.fetchProfile({ force: true })
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
it('returns null when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
const profile = await gate.fetchProfile()
expect(profile).toBeNull()
expect(gate.hasProfile.value).toBe(false)
expect(gate.profile.value).toBeNull()
expect(gate.profile.value).toBe(null)
expect(mockToastErrorHandler).toHaveBeenCalledOnce()
})
it('sets isFetchingProfile during fetch', async () => {
let resolvePromise: (v: Response) => void
mockFetchApi.mockReturnValue(
new Promise<Response>((resolve) => {
let resolvePromise: (v: ComfyHubProfile | null) => void
mockGetMyProfile.mockReturnValue(
new Promise<ComfyHubProfile | null>((resolve) => {
resolvePromise = resolve
})
)
@@ -117,7 +119,7 @@ describe('useComfyHubProfileGate', () => {
const promise = gate.fetchProfile()
expect(gate.isFetchingProfile.value).toBe(true)
resolvePromise!(mockSuccessResponse())
resolvePromise!(mockProfile)
await promise
expect(gate.isFetchingProfile.value).toBe(false)
@@ -126,7 +128,7 @@ describe('useComfyHubProfileGate', () => {
describe('checkProfile', () => {
it('returns true when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
mockGetMyProfile.mockResolvedValue(mockProfile)
const result = await gate.checkProfile()
@@ -134,105 +136,62 @@ describe('useComfyHubProfileGate', () => {
expect(gate.hasProfile.value).toBe(true)
})
it('returns false when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
it('returns false when no profile exists', async () => {
mockGetMyProfile.mockResolvedValue(null)
const result = await gate.checkProfile()
expect(result).toBe(false)
expect(gate.hasProfile.value).toBe(false)
})
it('returns cached value without re-fetching', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
const result = await gate.checkProfile()
expect(result).toBe(true)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('clears cached profile state when the authenticated user changes', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.checkProfile()
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
})
describe('createProfile', () => {
it('sends FormData with required username', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
const [url, options] = mockFetchApi.mock.calls[0]
expect(url).toBe('/hub/profile')
expect(options.method).toBe('POST')
const body = options.body as FormData
expect(body.get('username')).toBe('testuser')
})
it('includes optional fields when provided', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
const coverImage = new File(['img'], 'cover.png')
it('creates profile with workspace_id and avatar token', async () => {
const profilePicture = new File(['img'], 'avatar.png')
await gate.createProfile({
username: 'testuser',
name: 'Test User',
description: 'Hello',
coverImage,
profilePicture
})
const body = mockFetchApi.mock.calls[0][1].body as FormData
expect(body.get('name')).toBe('Test User')
expect(body.get('description')).toBe('Hello')
expect(body.get('cover_image')).toBe(coverImage)
expect(body.get('profile_picture')).toBe(profilePicture)
})
it('sets profile state on success', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
})
it('returns the created profile', async () => {
mockFetchApi.mockResolvedValue(
mockSuccessResponse({
username: 'testuser',
name: 'Test User',
description: 'A test profile',
cover_image_url: 'https://example.com/cover.png',
profile_picture_url: 'https://example.com/profile.png'
})
)
const profile = await gate.createProfile({ username: 'testuser' })
expect(profile).toEqual({
...mockProfile,
coverImageUrl: 'https://example.com/cover.png',
profilePictureUrl: 'https://example.com/profile.png'
expect(mockCreateProfile).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
username: 'testuser',
displayName: 'Test User',
description: 'Hello',
avatarToken: 'avatar-token'
})
})
it('throws with error message from API response', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
it('uploads avatar via upload-url + PUT before create', async () => {
const profilePicture = new File(['img'], 'avatar.png', {
type: 'image/png'
})
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
'Username taken'
)
await gate.createProfile({
username: 'testuser',
profilePicture
})
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'avatar.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/avatar.png',
file: profilePicture,
contentType: 'image/png'
})
const requestCallOrder =
mockRequestAssetUploadUrl.mock.invocationCallOrder
const uploadCallOrder =
mockUploadFileToPresignedUrl.mock.invocationCallOrder
const createCallOrder = mockCreateProfile.mock.invocationCallOrder
expect(requestCallOrder[0]).toBeLessThan(uploadCallOrder[0])
expect(uploadCallOrder[0]).toBeLessThan(createCallOrder[0])
})
})
})

View File

@@ -2,9 +2,9 @@ import { ref } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
// TODO: Migrate to a Pinia store for proper singleton state management
// User-scoped, session-cached profile state (module-level singleton)
@@ -15,14 +15,43 @@ const profile = ref<ComfyHubProfile | null>(null)
const cachedUserId = ref<string | null>(null)
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
const result = zHubProfileResponse.safeParse(payload)
return result.success ? result.data : null
function getCurrentWorkspaceId(): string {
const workspaceJson = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
)
if (!workspaceJson) {
throw new Error('Unable to determine current workspace')
}
let workspace: unknown
try {
workspace = JSON.parse(workspaceJson)
} catch {
throw new Error('Unable to determine current workspace')
}
if (
!workspace ||
typeof workspace !== 'object' ||
!('id' in workspace) ||
typeof workspace.id !== 'string' ||
workspace.id.length === 0
) {
throw new Error('Unable to determine current workspace')
}
return workspace.id
}
export function useComfyHubProfileGate() {
const { resolvedUserInfo } = useCurrentUser()
const { toastErrorHandler } = useErrorHandling()
const {
getMyProfile,
requestAssetUploadUrl,
uploadFileToPresignedUrl,
createProfile: createComfyHubProfile
} = useComfyHubService()
function syncCachedProfileWithCurrentUser(): void {
const currentUserId = resolvedUserInfo.value?.id ?? null
@@ -38,14 +67,7 @@ export function useComfyHubProfileGate() {
async function performFetch(): Promise<ComfyHubProfile | null> {
isFetchingProfile.value = true
try {
const response = await api.fetchApi('/hub/profile')
if (!response.ok) {
hasProfile.value = false
profile.value = null
return null
}
const nextProfile = mapHubProfileResponse(await response.json())
const nextProfile = await getMyProfile()
if (!nextProfile) {
hasProfile.value = false
profile.value = null
@@ -55,6 +77,7 @@ export function useComfyHubProfileGate() {
profile.value = nextProfile
return nextProfile
} catch (error) {
hasProfile.value = false
toastErrorHandler(error)
return null
} finally {
@@ -95,37 +118,35 @@ export function useComfyHubProfileGate() {
username: string
name?: string
description?: string
coverImage?: File
profilePicture?: File
}): Promise<ComfyHubProfile> {
syncCachedProfileWithCurrentUser()
const formData = new FormData()
formData.append('username', data.username)
if (data.name) formData.append('name', data.name)
if (data.description) formData.append('description', data.description)
if (data.coverImage) formData.append('cover_image', data.coverImage)
if (data.profilePicture)
formData.append('profile_picture', data.profilePicture)
let avatarToken: string | undefined
if (data.profilePicture) {
const contentType = data.profilePicture.type || 'application/octet-stream'
const upload = await requestAssetUploadUrl({
filename: data.profilePicture.name,
contentType
})
const response = await api.fetchApi('/hub/profile', {
method: 'POST',
body: formData
await uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file: data.profilePicture,
contentType
})
avatarToken = upload.token
}
const createdProfile = await createComfyHubProfile({
workspaceId: getCurrentWorkspaceId(),
username: data.username,
displayName: data.name,
description: data.description,
avatarToken
})
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}))
const message =
body && typeof body === 'object' && 'message' in body
? String((body as Record<string, unknown>).message)
: 'Failed to create profile'
throw new Error(message)
}
const createdProfile = mapHubProfileResponse(await response.json())
if (!createdProfile) {
throw new Error('Invalid profile response from server')
}
hasProfile.value = true
profile.value = createdProfile
return createdProfile

View File

@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
const mockProfile = vi.hoisted(
() => ({ value: null }) as { value: ComfyHubProfile | null }
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
profile: mockProfile
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getShareableAssets: mockGetShareableAssets
})
}))
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
publishWorkflow: mockPublishWorkflow
})
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
path: 'workflows/demo-workflow.json'
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
const { useComfyHubPublishSubmission } =
await import('./useComfyHubPublishSubmission')
function createFormData(
overrides: Partial<ComfyHubPublishFormData> = {}
): ComfyHubPublishFormData {
return {
name: 'Demo workflow',
description: 'A demo workflow',
tags: ['demo'],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
...overrides
}
}
describe('useComfyHubPublishSubmission', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProfile.value = {
username: 'builder',
name: 'Builder'
}
mockGetShareableAssets.mockResolvedValue([
{ id: 'asset-1' },
{ id: 'asset-2' }
])
let uploadIndex = 0
mockRequestAssetUploadUrl.mockImplementation(
async ({ filename }: { filename: string }) => {
uploadIndex += 1
return {
uploadUrl: `https://upload.example.com/${filename}`,
publicUrl: `https://cdn.example.com/${filename}`,
token: `token-${uploadIndex}`
}
}
)
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockPublishWorkflow.mockResolvedValue({
share_id: 'share-1',
workflow_id: 'workflow-1'
})
})
it('passes imageComparison thumbnail type to service for normalization', async () => {
const beforeFile = new File(['before'], 'before.png', { type: 'image/png' })
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
thumbnailFile: null,
comparisonBeforeFile: beforeFile,
comparisonAfterFile: afterFile
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'imageComparison'
})
)
})
it('uploads thumbnail and returns thumbnail token', async () => {
const thumbnailFile = new File(['thumbnail'], 'thumb.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/thumb.png',
file: thumbnailFile,
contentType: 'image/png'
})
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('uploads all example images', async () => {
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
exampleImages: [
{ id: 'a', file: file1, url: 'blob:a' },
{ id: 'b', file: file2, url: 'blob:b' }
]
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(2)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
sampleImageTokensOrUrls: ['token-1', 'token-2']
})
)
})
it('builds publish request with workflow filename + asset ids', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(createFormData())
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
username: 'builder',
workflowFilename: 'workflows/demo-workflow.json',
assetIds: ['asset-1', 'asset-2'],
name: 'Demo workflow',
description: 'A demo workflow',
tags: ['demo']
})
)
})
it('throws when profile username is unavailable', async () => {
mockProfile.value = null
const { submitToComfyHub } = useComfyHubPublishSubmission()
await expect(submitToComfyHub(createFormData())).rejects.toThrow(
'ComfyHub profile is required before publishing'
)
})
})

View File

@@ -0,0 +1,121 @@
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
function getFileContentType(file: File): string {
return file.type || 'application/octet-stream'
}
function getUsername(profile: ComfyHubProfile | null): string {
const username = profile?.username?.trim()
if (!username) {
throw new Error('ComfyHub profile is required before publishing')
}
return username
}
function getWorkflowFilename(path: string | null | undefined): string {
const workflowFilename = path?.trim()
if (!workflowFilename) {
throw new Error('No active workflow file available for publishing')
}
return workflowFilename
}
function getAssetIds(assets: AssetInfo[]): string[] {
return assets.map((asset) => asset.id)
}
function resolveThumbnailFile(
formData: ComfyHubPublishFormData
): File | undefined {
if (formData.thumbnailType === 'imageComparison') {
return formData.comparisonBeforeFile ?? undefined
}
return formData.thumbnailFile ?? undefined
}
export function useComfyHubPublishSubmission() {
const { profile } = useComfyHubProfileGate()
const workflowStore = useWorkflowStore()
const workflowShareService = useWorkflowShareService()
const comfyHubService = useComfyHubService()
async function uploadFileAndGetToken(file: File): Promise<string> {
const contentType = getFileContentType(file)
const upload = await comfyHubService.requestAssetUploadUrl({
filename: file.name,
contentType
})
await comfyHubService.uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file,
contentType
})
return upload.token
}
async function submitToComfyHub(
formData: ComfyHubPublishFormData
): Promise<void> {
const username = getUsername(profile.value)
const workflowFilename = getWorkflowFilename(
workflowStore.activeWorkflow?.path
)
const assetIds = getAssetIds(
await workflowShareService.getShareableAssets()
)
const thumbnailFile = resolveThumbnailFile(formData)
const thumbnailTokenOrUrl = thumbnailFile
? await uploadFileAndGetToken(thumbnailFile)
: undefined
const thumbnailComparisonTokenOrUrl =
formData.thumbnailType === 'imageComparison' &&
formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: undefined
const sampleImageTokensOrUrls =
formData.exampleImages.length > 0
? await Promise.all(
formData.exampleImages.map((image) =>
image.file ? uploadFileAndGetToken(image.file) : image.url
)
)
: undefined
await comfyHubService.publishWorkflow({
username,
name: formData.name,
workflowFilename,
assetIds,
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
models: formData.models.length > 0 ? formData.models : undefined,
customNodes:
formData.customNodes.length > 0 ? formData.customNodes : undefined,
thumbnailType: formData.thumbnailType,
thumbnailTokenOrUrl,
thumbnailComparisonTokenOrUrl,
sampleImageTokensOrUrls,
tutorialUrl: formData.tutorialUrl || undefined,
metadata:
Object.keys(formData.metadata).length > 0
? formData.metadata
: undefined
})
}
return {
submitToComfyHub
}
}

View File

@@ -35,14 +35,12 @@ describe('useComfyHubPublishWizard', () => {
it('initialises all other form fields to defaults', () => {
const { formData } = useComfyHubPublishWizard()
expect(formData.value.description).toBe('')
expect(formData.value.workflowType).toBe('')
expect(formData.value.tags).toEqual([])
expect(formData.value.thumbnailType).toBe('image')
expect(formData.value.thumbnailFile).toBeNull()
expect(formData.value.comparisonBeforeFile).toBeNull()
expect(formData.value.comparisonAfterFile).toBeNull()
expect(formData.value.exampleImages).toEqual([])
expect(formData.value.selectedExampleIds).toEqual([])
})
})

View File

@@ -1,8 +1,14 @@
import { useStepper } from '@vueuse/core'
import { v4 as uuidv4 } from 'uuid'
import { computed, ref } from 'vue'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type {
ComfyHubPublishFormData,
ExampleImage
} from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
const PUBLISH_STEPS = [
'describe',
@@ -13,22 +19,55 @@ const PUBLISH_STEPS = [
export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
// TODO: Migrate to a Pinia store alongside the profile gate singleton
const cachedPrefills = new Map<string, PublishPrefill>()
function createDefaultFormData(): ComfyHubPublishFormData {
const { activeWorkflow } = useWorkflowStore()
const workflowStore = useWorkflowStore()
return {
name: activeWorkflow?.filename ?? '',
name: workflowStore.activeWorkflow?.filename ?? '',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}
}
function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
return urls.map((url) => ({ id: uuidv4(), url }))
}
function extractPrefillFromFormData(
formData: ComfyHubPublishFormData
): PublishPrefill {
return {
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
thumbnailType: formData.thumbnailType,
sampleImageUrls: formData.exampleImages
.map((img) => img.url)
.filter((url) => !url.startsWith('blob:'))
}
}
export function cachePublishPrefill(
workflowPath: string,
formData: ComfyHubPublishFormData
) {
cachedPrefills.set(workflowPath, extractPrefillFromFormData(formData))
}
export function getCachedPrefill(workflowPath: string): PublishPrefill | null {
return cachedPrefills.get(workflowPath) ?? null
}
export function useComfyHubPublishWizard() {
const stepper = useStepper([...PUBLISH_STEPS])
const formData = ref<ComfyHubPublishFormData>(createDefaultFormData())
@@ -53,6 +92,30 @@ export function useComfyHubPublishWizard() {
stepper.goTo('finish')
}
function applyPrefill(prefill: PublishPrefill) {
const defaults = createDefaultFormData()
const current = formData.value
formData.value = {
...current,
description:
current.description === defaults.description
? (prefill.description ?? current.description)
: current.description,
tags:
current.tags.length === 0 && prefill.tags?.length
? prefill.tags
: current.tags,
thumbnailType:
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType,
exampleImages:
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
? createExampleImagesFromUrls(prefill.sampleImageUrls)
: current.exampleImages
}
}
return {
currentStep: stepper.current,
formData,
@@ -64,6 +127,7 @@ export function useComfyHubPublishWizard() {
goNext: stepper.goToNext,
goBack: stepper.goToPrevious,
openProfileCreationStep,
closeProfileCreationStep
closeProfileCreationStep,
applyPrefill
}
}

View File

@@ -53,7 +53,6 @@ const COMFY_HUB_TAG_FREQUENCIES = [
{ tag: 'Lip Sync', count: 2 },
{ tag: 'Multiple Angles', count: 2 },
{ tag: 'Remove Background', count: 2 },
{ tag: 'Text-to-Image', count: 2 },
{ tag: 'Vector', count: 2 },
{ tag: 'Brand', count: 1 },
{ tag: 'Canny', count: 1 },

View File

@@ -10,6 +10,15 @@ export const zPublishRecordResponse = z.object({
assets: z.array(zAssetInfo).optional()
})
export const zHubWorkflowPrefillResponse = z.object({
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
sample_image_urls: z.array(z.string()).nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
thumbnail_url: z.string().nullish(),
thumbnail_comparison_url: z.string().nullish()
})
/**
* Strips path separators and control characters from a workflow name to prevent
* path traversal when the name is later used as part of a file path.
@@ -36,9 +45,28 @@ export const zHubProfileResponse = z.preprocess((data) => {
const d = data as Record<string, unknown>
return {
username: d.username,
name: d.name,
name: d.name ?? d.display_name,
description: d.description,
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
profilePictureUrl:
d.profilePictureUrl ?? d.profile_picture_url ?? d.avatar_url
}
}, zComfyHubProfile)
export const zHubAssetUploadUrlResponse = z
.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
.transform((response) => ({
uploadUrl: response.upload_url,
publicUrl: response.public_url,
token: response.token
}))
export const zHubWorkflowPublishResponse = z.object({
share_id: z.string(),
workflow_id: z.string(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
})

View File

@@ -0,0 +1,198 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGlobalFetch = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
const { useComfyHubService } = await import('./comfyHubService')
function mockJsonResponse(payload: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => payload
} as Response
}
function mockUploadResponse(ok = true, status = 200): Response {
return {
ok,
status,
json: async () => ({})
} as Response
}
describe('useComfyHubService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('fetch', mockGlobalFetch)
})
afterAll(() => {
vi.unstubAllGlobals()
})
it('requests upload url and returns token payload', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
upload_url: 'https://upload.example.com/object',
public_url: 'https://cdn.example.com/object',
token: 'upload-token'
})
)
const service = useComfyHubService()
const result = await service.requestAssetUploadUrl({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: 'thumb.png',
content_type: 'image/png'
})
})
expect(result).toEqual({
uploadUrl: 'https://upload.example.com/object',
publicUrl: 'https://cdn.example.com/object',
token: 'upload-token'
})
})
it('uploads file to presigned url with PUT', async () => {
mockGlobalFetch.mockResolvedValue(mockUploadResponse())
const service = useComfyHubService()
const file = new File(['payload'], 'avatar.png', { type: 'image/png' })
await service.uploadFileToPresignedUrl({
uploadUrl: 'https://upload.example.com/object',
file,
contentType: 'image/png'
})
expect(mockGlobalFetch).toHaveBeenCalledWith(
'https://upload.example.com/object',
{
method: 'PUT',
headers: {
'Content-Type': 'image/png'
},
body: file
}
)
})
it('creates profile with workspace_id JSON body', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.createProfile({
workspaceId: 'workspace-1',
username: 'builder',
displayName: 'Builder',
description: 'Builds workflows',
avatarToken: 'avatar-token'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: 'workspace-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_token: 'avatar-token'
})
})
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
it('publishes workflow with mapped thumbnail enum', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
share_id: 'share-1',
workflow_id: 'workflow-1',
thumbnail_type: 'image_comparison'
})
)
const service = useComfyHubService()
await service.publishWorkflow({
username: 'builder',
name: 'My Flow',
workflowFilename: 'workflows/my-flow.json',
assetIds: ['asset-1'],
thumbnailType: 'imageComparison',
thumbnailTokenOrUrl: 'thumb-token',
thumbnailComparisonTokenOrUrl: 'thumb-compare-token',
sampleImageTokensOrUrls: ['sample-1']
})
const [, options] = mockFetchApi.mock.calls[0]
const body = JSON.parse(options.body as string)
expect(body).toMatchObject({
username: 'builder',
name: 'My Flow',
workflow_filename: 'workflows/my-flow.json',
asset_ids: ['asset-1'],
thumbnail_type: 'image_comparison',
thumbnail_token_or_url: 'thumb-token',
thumbnail_comparison_token_or_url: 'thumb-compare-token',
sample_image_tokens_or_urls: ['sample-1']
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
})
it('fetches current profile from /hub/profiles/me', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.getMyProfile()
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles/me')
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
})

View File

@@ -0,0 +1,223 @@
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import {
zHubAssetUploadUrlResponse,
zHubProfileResponse,
zHubWorkflowPublishResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
import { api } from '@/scripts/api'
type HubThumbnailType = 'image' | 'video' | 'image_comparison'
type ThumbnailTypeInput = HubThumbnailType | 'imageComparison'
interface CreateProfileInput {
workspaceId: string
username: string
displayName?: string
description?: string
avatarToken?: string
}
interface PublishWorkflowInput {
username: string
name: string
workflowFilename: string
assetIds: string[]
description?: string
tags?: string[]
models?: string[]
customNodes?: string[]
thumbnailType?: ThumbnailTypeInput
thumbnailTokenOrUrl?: string
thumbnailComparisonTokenOrUrl?: string
sampleImageTokensOrUrls?: string[]
tutorialUrl?: string
metadata?: Record<string, unknown>
}
function normalizeThumbnailType(type: ThumbnailTypeInput): HubThumbnailType {
if (type === 'imageComparison') {
return 'image_comparison'
}
return type
}
async function parseErrorMessage(
response: Response,
fallbackMessage: string
): Promise<string> {
const body = await response.json().catch(() => null)
if (!body || typeof body !== 'object') {
return fallbackMessage
}
if ('message' in body && typeof body.message === 'string') {
return body.message
}
return fallbackMessage
}
async function parseRequiredJson<T>(
response: Response,
parser: {
safeParse: (
value: unknown
) => { success: true; data: T } | { success: false }
},
fallbackMessage: string
): Promise<T> {
const payload = await response.json().catch(() => null)
const parsed = parser.safeParse(payload)
if (!parsed.success) {
throw new Error(fallbackMessage)
}
return parsed.data
}
export function useComfyHubService() {
async function requestAssetUploadUrl(input: {
filename: string
contentType: string
}) {
const response = await api.fetchApi('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: input.filename,
content_type: input.contentType
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to request upload URL')
)
}
return parseRequiredJson(
response,
zHubAssetUploadUrlResponse,
'Invalid upload URL response from server'
)
}
async function uploadFileToPresignedUrl(input: {
uploadUrl: string
file: File
contentType: string
}): Promise<void> {
const response = await fetch(input.uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': input.contentType
},
body: input.file
})
if (!response.ok) {
const message = await parseErrorMessage(
response,
'Failed to upload file to presigned URL'
)
throw new Error(message)
}
}
async function getMyProfile(): Promise<ComfyHubProfile | null> {
const response = await api.fetchApi('/hub/profiles/me')
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error(
await parseErrorMessage(response, 'Failed to load ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function createProfile(
input: CreateProfileInput
): Promise<ComfyHubProfile> {
const response = await api.fetchApi('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: input.workspaceId,
username: input.username,
display_name: input.displayName,
description: input.description,
avatar_token: input.avatarToken
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to create ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function publishWorkflow(input: PublishWorkflowInput) {
const body = {
username: input.username,
name: input.name,
workflow_filename: input.workflowFilename,
asset_ids: input.assetIds,
description: input.description,
tags: input.tags,
models: input.models,
custom_nodes: input.customNodes,
thumbnail_type: input.thumbnailType
? normalizeThumbnailType(input.thumbnailType)
: undefined,
thumbnail_token_or_url: input.thumbnailTokenOrUrl,
thumbnail_comparison_token_or_url: input.thumbnailComparisonTokenOrUrl,
sample_image_tokens_or_urls: input.sampleImageTokensOrUrls,
tutorial_url: input.tutorialUrl,
metadata: input.metadata
}
const response = await api.fetchApi('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to publish workflow')
)
}
return parseRequiredJson(
response,
zHubWorkflowPublishResponse,
'Invalid publish response from server'
)
}
return {
requestAssetUploadUrl,
uploadFileToPresignedUrl,
getMyProfile,
createProfile,
publishWorkflow
}
}

View File

@@ -163,6 +163,82 @@ describe(useWorkflowShareService, () => {
expect(status.publishedAt).toBeInstanceOf(Date)
})
it('includes prefill data from hub workflow details', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-prefill/publish') {
return mockJsonResponse({
workflow_id: 'wf-prefill',
share_id: 'wf-prefill',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-prefill') {
return mockJsonResponse({
description: 'A cool workflow',
tags: ['art', 'upscale'],
thumbnail_type: 'image_comparison',
sample_image_urls: ['https://example.com/img1.png']
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-prefill')
expect(status.isPublished).toBe(true)
expect(status.prefill).toEqual({
description: 'A cool workflow',
tags: ['art', 'upscale'],
thumbnailType: 'imageComparison',
sampleImageUrls: ['https://example.com/img1.png']
})
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
})
it('returns null prefill when hub workflow details are unavailable', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-no-meta/publish') {
return mockJsonResponse({
workflow_id: 'wf-no-meta',
share_id: 'wf-no-meta',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
return mockJsonResponse({}, false, 500)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-no-meta')
expect(status.isPublished).toBe(true)
expect(status.prefill).toBeNull()
})
it('does not fetch hub workflow details when publish record is unlisted', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
workflow_id: 'wf-unlisted',
share_id: 'wf-unlisted',
publish_time: '2026-02-23T00:00:00Z',
listed: false
})
)
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-unlisted')
expect(status.isPublished).toBe(true)
expect(status.prefill).toBeNull()
expect(mockFetchApi).toHaveBeenCalledTimes(1)
expect(mockFetchApi).toHaveBeenCalledWith('/userdata/wf-unlisted/publish')
})
it('preserves app subpath when normalizing publish status share URLs', async () => {
window.history.replaceState({}, '', '/comfy/subpath/')
mockFetchApi.mockResolvedValue(
@@ -303,7 +379,8 @@ describe(useWorkflowShareService, () => {
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null
publishedAt: null,
prefill: null
})
})

View File

@@ -1,12 +1,15 @@
import type {
PublishPrefill,
SharedWorkflowPayload,
WorkflowPublishResult,
WorkflowPublishStatus
} from '@/platform/workflow/sharing/types/shareTypes'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AssetInfo } from '@/schemas/apiSchema'
import {
zHubWorkflowPrefillResponse,
zPublishRecordResponse,
zSharedWorkflowResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
@@ -28,6 +31,45 @@ class SharedWorkflowLoadError extends Error {
}
}
function mapApiThumbnailType(
value: 'image' | 'video' | 'image_comparison' | null | undefined
): ThumbnailType | undefined {
if (!value) return undefined
if (value === 'image_comparison') return 'imageComparison'
return value
}
interface PrefillMetadataFields {
description?: string | null
tags?: string[] | null
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
sample_image_urls?: string[] | null
}
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
const description = fields.description ?? undefined
const tags = fields.tags ?? undefined
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
const sampleImageUrls = fields.sample_image_urls ?? undefined
if (
!description &&
!tags?.length &&
!thumbnailType &&
!sampleImageUrls?.length
) {
return null
}
return { description, tags, thumbnailType, sampleImageUrls }
}
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
const result = zHubWorkflowPrefillResponse.safeParse(payload)
if (!result.success) return null
return extractPrefill(result.data)
}
function decodePublishRecord(payload: unknown) {
const result = zPublishRecordResponse.safeParse(payload)
if (!result.success) return null
@@ -37,7 +79,8 @@ function decodePublishRecord(payload: unknown) {
shareId: r.share_id ?? undefined,
listed: r.listed,
publishedAt: parsePublishedAt(r.publish_time),
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined,
prefill: null
}
}
@@ -81,10 +124,27 @@ const UNPUBLISHED = {
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null
publishedAt: null,
prefill: null
} as const satisfies WorkflowPublishStatus
export function useWorkflowShareService() {
async function fetchHubWorkflowPrefill(
shareId: string
): Promise<PublishPrefill | null> {
const response = await api.fetchApi(
`/hub/workflows/${encodeURIComponent(shareId)}`
)
if (!response.ok) {
throw new Error(
`Failed to fetch hub workflow details: ${response.status}`
)
}
const prefill = decodeHubWorkflowPrefill(await response.json())
return prefill
}
async function publishWorkflow(
workflowPath: string,
shareableAssets: AssetInfo[]
@@ -132,11 +192,21 @@ export function useWorkflowShareService() {
const record = decodePublishRecord(json)
if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
let prefill: PublishPrefill | null = record.prefill
if (!prefill && record.listed) {
try {
prefill = await fetchHubWorkflowPrefill(record.shareId)
} catch {
prefill = null
}
}
return {
isPublished: true,
shareId: record.shareId,
shareUrl: normalizeShareUrl(record.shareId),
publishedAt: record.publishedAt
publishedAt: record.publishedAt,
prefill
}
}

View File

@@ -1,11 +1,5 @@
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
export type ComfyHubWorkflowType =
| 'imageGeneration'
| 'videoGeneration'
| 'upscaling'
| 'editing'
export interface ExampleImage {
id: string
url: string
@@ -15,12 +9,14 @@ export interface ExampleImage {
export interface ComfyHubPublishFormData {
name: string
description: string
workflowType: ComfyHubWorkflowType | ''
tags: string[]
models: string[]
customNodes: string[]
thumbnailType: ThumbnailType
thumbnailFile: File | null
comparisonBeforeFile: File | null
comparisonAfterFile: File | null
exampleImages: ExampleImage[]
selectedExampleIds: string[]
tutorialUrl: string
metadata: Record<string, unknown>
}

View File

@@ -1,5 +1,6 @@
import type { AssetInfo } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
export interface WorkflowPublishResult {
publishedAt: Date
@@ -7,13 +8,27 @@ export interface WorkflowPublishResult {
shareUrl: string
}
export interface PublishPrefill {
description?: string
tags?: string[]
thumbnailType?: ThumbnailType
sampleImageUrls?: string[]
}
export type WorkflowPublishStatus =
| { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
| {
isPublished: false
publishedAt: null
shareId: null
shareUrl: null
prefill: null
}
| {
isPublished: true
publishedAt: Date
shareId: string
shareUrl: string
prefill: PublishPrefill | null
}
export interface SharedWorkflowPayload {

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { normalizeTag, normalizeTags } from './normalizeTags'
describe('normalizeTag', () => {
it.for([
{ input: 'Text to Image', expected: 'text-to-image', name: 'spaces' },
{ input: 'API', expected: 'api', name: 'single word' },
{
input: 'text-to-image',
expected: 'text-to-image',
name: 'already normalized'
},
{
input: 'Image Upscale',
expected: 'image-upscale',
name: 'multiple spaces'
},
{
input: ' Video ',
expected: 'video',
name: 'leading/trailing whitespace'
},
{ input: ' ', expected: '', name: 'whitespace-only' }
])('$name: "$input" → "$expected"', ({ input, expected }) => {
expect(normalizeTag(input)).toBe(expected)
})
})
describe('normalizeTags', () => {
it.for([
{
name: 'normalizes all tags',
input: ['Text to Image', 'API', 'Video'],
expected: ['text-to-image', 'api', 'video']
},
{
name: 'deduplicates tags with the same slug',
input: ['Text to Image', 'Text-to-Image'],
expected: ['text-to-image']
},
{
name: 'filters out empty tags',
input: ['Video', '', ' ', 'Audio'],
expected: ['video', 'audio']
},
{
name: 'returns empty array for empty input',
input: [],
expected: []
}
])('$name', ({ input, expected }) => {
expect(normalizeTags(input)).toEqual(expected)
})
})

View File

@@ -0,0 +1,14 @@
/**
* Normalizes a tag to its slug form for the ComfyHub API.
* Converts display names like "Text to Image" to "text-to-image".
*/
export function normalizeTag(tag: string): string {
return tag.trim().toLowerCase().replace(/\s+/g, '-')
}
/**
* Normalizes and deduplicates an array of tags for API submission.
*/
export function normalizeTags(tags: string[]): string[] {
return [...new Set(tags.map(normalizeTag).filter(Boolean))]
}

View File

@@ -580,6 +580,9 @@ export class ComfyApp {
// Get prompt from dropped PNG or json
useEventListener(document, 'drop', async (event: DragEvent) => {
try {
// Skip if already handled (e.g. file drop onto publish dialog tiles)
if (event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()

View File

@@ -123,12 +123,13 @@ export const useCustomerEventsService = () => {
function formatJsonValue(value: unknown) {
if (typeof value === 'number') {
// Format numbers with commas and decimals if needed
return value.toLocaleString()
}
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
// Format dates nicely
return new Date(value).toLocaleString()
if (typeof value === 'string') {
const date = new Date(value)
if (!Number.isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return d(date, { dateStyle: 'medium', timeStyle: 'short' })
}
}
return value
}