Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
b3fe8da9b5 fix 2025-11-13 15:29:22 -08:00
bymyself
a1c92274fb refactor 2025-11-13 09:50:54 -08:00
49 changed files with 878 additions and 2853 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,6 +1,7 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
@@ -60,7 +61,8 @@ export const useFirebaseAuthActions = () => {
if (isCloud) {
try {
window.location.href = '/cloud/login'
const router = useRouter()
await router.push({ name: 'cloud-login' })
} catch (error) {
// needed for local development until we bring in cloud login pages.
window.location.reload()

View File

@@ -180,10 +180,6 @@
"title": "Title",
"edit": "Edit",
"copy": "Copy",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"jobIdCopied": "Job ID copied to clipboard",
"failedToCopyJobId": "Failed to copy job ID",
"imageUrl": "Image URL",
"clear": "Clear",
"clearAll": "Clear all",
@@ -611,8 +607,17 @@
"nodes": "Nodes",
"models": "Models",
"workflows": "Workflows",
"templates": "Templates"
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"assets": "Assets",
"imported": "Imported",
"generated": "Generated"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
@@ -754,310 +759,6 @@
"Partner Nodes": "Partner Nodes",
"Generation Type": "Generation Type"
},
"templateDescription": {
"Basics": {
"default": "Generate images from text prompts.",
"image2image": "Transform existing images using text prompts.",
"lora": "Generate images with LoRA models for specialized styles or subjects.",
"lora_multiple": "Generate images by combining multiple LoRA models.",
"inpaint_example": "Edit specific parts of images seamlessly.",
"inpaint_model_outpainting": "Extend images beyond their original boundaries.",
"embedding_example": "Generate images using textual inversion for consistent styles.",
"gligen_textbox_example": "Generate images with precise object placement using text boxes."
},
"Flux": {
"flux_kontext_dev_basic": "Edit image using Flux Kontext with full node visibility, perfect for learning the workflow.",
"flux_kontext_dev_grouped": "Streamlined version of Flux Kontext with grouped nodes for cleaner workspace.",
"flux_dev_checkpoint_example": "Generate images using Flux Dev fp8 quantized version. Suitable for devices with limited VRAM, requires only one model file, but image quality is slightly lower than the full version.",
"flux_schnell": "Quickly generate images with Flux Schnell fp8 quantized version. Ideal for low-end hardware, requires only 4 steps to generate images.",
"flux_dev_full_text_to_image": "Generate high-quality images with Flux Dev full version. Requires larger VRAM and multiple model files, but provides the best prompt following capability and image quality.",
"flux_schnell_full_text_to_image": "Generate images quickly with Flux Schnell full version. Uses Apache2.0 license, requires only 4 steps to generate images while maintaining good image quality.",
"flux_fill_inpaint_example": "Fill missing parts of images using Flux inpainting.",
"flux_fill_outpaint_example": "Extend images beyond boundaries using Flux outpainting.",
"flux_canny_model_example": "Generate images guided by edge detection using Flux Canny.",
"flux_depth_lora_example": "Generate images guided by depth information using Flux LoRA.",
"flux_redux_model_example": "Generate images by transferring style from reference images using Flux Redux."
},
"Image": {
"image_omnigen2_t2i": "Generate high-quality images from text prompts using OmniGen2's unified 7B multimodal model with dual-path architecture.",
"image_omnigen2_image_edit": "Edit images with natural language instructions using OmniGen2's advanced image editing capabilities and text rendering support.",
"image_cosmos_predict2_2B_t2i": "Generate images with Cosmos-Predict2 2B T2I, delivering physically accurate, high-fidelity, and detail-rich image generation.",
"image_chroma_text_to_image": "Chroma is modified from flux and has some changes in the architecture.",
"hidream_i1_dev": "Generate images with HiDream I1 Dev - Balanced version with 28 inference steps, suitable for medium-range hardware.",
"hidream_i1_fast": "Generate images quickly with HiDream I1 Fast - Lightweight version with 16 inference steps, ideal for rapid previews on lower-end hardware.",
"hidream_i1_full": "Generate images with HiDream I1 Full - Complete version with 50 inference steps for highest quality output.",
"hidream_e1_full": "Edit images with HiDream E1 - Professional natural language image editing model.",
"sd3_5_simple_example": "Generate images using SD 3.5.",
"sd3_5_large_canny_controlnet_example": "Generate images guided by edge detection using SD 3.5 Canny ControlNet.",
"sd3_5_large_depth": "Generate images guided by depth information using SD 3.5.",
"sd3_5_large_blur": "Generate images guided by blurred reference images using SD 3.5.",
"sdxl_simple_example": "Generate high-quality images using SDXL.",
"sdxl_refiner_prompt_example": "Enhance SDXL images using refiner models.",
"sdxl_revision_text_prompts": "Generate images by transferring concepts from reference images using SDXL Revision.",
"sdxl_revision_zero_positive": "Generate images using both text prompts and reference images with SDXL Revision.",
"sdxlturbo_example": "Generate images in a single step using SDXL Turbo.",
"image_lotus_depth_v1_1": "Run Lotus Depth in ComfyUI for zero-shot, efficient monocular depth estimation with high detail retention."
},
"Video": {
"video_cosmos_predict2_2B_video2world_480p_16fps": "Generate videos with Cosmos-Predict2 2B Video2World, generating physically accurate, high-fidelity, and consistent video simulations.",
"video_wan_vace_14B_t2v": "Transform text descriptions into high-quality videos. Supports both 480p and 720p with VACE-14B model.",
"video_wan_vace_14B_ref2v": "Create videos that match the style and content of a reference image. Perfect for style-consistent video generation.",
"video_wan_vace_14B_v2v": "Generate videos by controlling input videos and reference images using Wan VACE.",
"video_wan_vace_outpainting": "Generate extended videos by expanding video size using Wan VACE outpainting.",
"video_wan_vace_flf2v": "Generate smooth video transitions by defining start and end frames. Supports custom keyframe sequences.",
"video_wan_vace_inpainting": "Edit specific regions in videos while preserving surrounding content. Great for object removal or replacement.",
"video_wan2_1_fun_camera_v1_1_1_3B": "Generate dynamic videos with cinematic camera movements using Wan 2.1 Fun Camera 1.3B model.",
"video_wan2_1_fun_camera_v1_1_14B": "Generate high-quality videos with advanced camera control using the full 14B model",
"text_to_video_wan": "Generate videos from text prompts using Wan 2.1.",
"image_to_video_wan": "Generate videos from images using Wan 2.1.",
"wan2_1_fun_inp": "Generate videos from start and end frames using Wan 2.1 inpainting.",
"wan2_1_fun_control": "Generate videos guided by pose, depth, and edge controls using Wan 2.1 ControlNet.",
"wan2_1_flf2v_720_f16": "Generate videos by controlling first and last frames using Wan 2.1 FLF2V.",
"ltxv_text_to_video": "Generate videos from text prompts.",
"ltxv_image_to_video": "Generate videos from still images.",
"mochi_text_to_video_example": "Generate videos from text prompts using Mochi model.",
"hunyuan_video_text_to_video": "Generate videos from text prompts using Hunyuan model.",
"image_to_video": "Generate videos from still images.",
"txt_to_image_to_video": "Generate videos by first creating images from text prompts."
},
"Image API": {
"api_bfl_flux_1_kontext_multiple_images_input": "Input multiple images and edit them with Flux.1 Kontext.",
"api_bfl_flux_1_kontext_pro_image": "Edit images with Flux.1 Kontext pro image.",
"api_bfl_flux_1_kontext_max_image": "Edit images with Flux.1 Kontext max image.",
"api_bfl_flux_pro_t2i": "Generate images with excellent prompt following and visual quality using FLUX.1 Pro.",
"api_luma_photon_i2i": "Guide image generation using a combination of images and prompt.",
"api_luma_photon_style_ref": "Generate images by blending style references with precise control using Luma Photon.",
"api_recraft_image_gen_with_color_control": "Generate images with custom color palettes and brand-specific visuals using Recraft.",
"api_recraft_image_gen_with_style_control": "Control style with visual examples, align positioning, and fine-tune objects. Store and share styles for perfect brand consistency.",
"api_recraft_vector_gen": "Generate high-quality vector images from text prompts using Recraft's AI vector generator.",
"api_runway_text_to_image": "Generate high-quality images from text prompts using Runway's AI model.",
"api_runway_reference_to_image": "Generate new images based on reference styles and compositions with Runway's AI.",
"api_stability_ai_stable_image_ultra_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_stability_ai_i2i": "Transform images with high-quality generation using Stability AI, perfect for professional editing and style transfer.",
"api_stability_ai_sd3_5_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_stability_ai_sd3_5_i2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_ideogram_v3_t2i": "Generate professional-quality images with excellent prompt alignment, photorealism, and text rendering using Ideogram V3.",
"api_openai_image_1_t2i": "Generate images from text prompts using OpenAI GPT Image 1 API.",
"api_openai_image_1_i2i": "Generate images from input images using OpenAI GPT Image 1 API.",
"api_openai_image_1_inpaint": "Edit images using inpainting with OpenAI GPT Image 1 API.",
"api_openai_image_1_multi_inputs": "Generate images from multiple inputs using OpenAI GPT Image 1 API.",
"api_openai_dall_e_2_t2i": "Generate images from text prompts using OpenAI Dall-E 2 API.",
"api_openai_dall_e_2_inpaint": "Edit images using inpainting with OpenAI Dall-E 2 API.",
"api_openai_dall_e_3_t2i": "Generate images from text prompts using OpenAI Dall-E 3 API."
},
"Video API": {
"api_moonvalley_text_to_video": "Generate cinematic, 1080p videos from text prompts through a model trained exclusively on licensed data.",
"api_moonvalley_image_to_video": "Generate cinematic, 1080p videos with an image through a model trained exclusively on licensed data.",
"api_kling_i2v": "Generate videos with excellent prompt adherence for actions, expressions, and camera movements using Kling.",
"api_kling_effects": "Generate dynamic videos by applying visual effects to images using Kling.",
"api_kling_flf": "Generate videos through controlling the first and last frames.",
"api_luma_i2v": "Take static images and instantly create magical high quality animations.",
"api_luma_t2v": "High-quality videos can be generated using simple prompts.",
"api_hailuo_minimax_t2v": "Generate high-quality videos directly from text prompts. Explore MiniMax's advanced AI capabilities to create diverse visual narratives with professional CGI effects and stylistic elements to bring your descriptions to life.",
"api_hailuo_minimax_i2v": "Generate refined videos from images and text with CGI integration using MiniMax.",
"api_pixverse_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
"api_pixverse_template_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
"api_pixverse_t2v": "Generate videos with accurate prompt interpretation and stunning video dynamics.",
"api_runway_gen3a_turbo_image_to_video": "Generate cinematic videos from static images using Runway Gen3a Turbo.",
"api_runway_gen4_turo_image_to_video": "Generate dynamic videos from images using Runway Gen4 Turbo.",
"api_runway_first_last_frame": "Generate smooth video transitions between two keyframes with Runway's precision.",
"api_pika_i2v": "Generate smooth animated videos from single static images using Pika AI.",
"api_pika_scene": "Generate videos that incorporate multiple input images using Pika Scenes.",
"api_veo2_i2v": "Generate videos from images using Google Veo2 API."
},
"3D API": {
"api_rodin_image_to_model": "Generate detailed 3D models from single photos using Rodin AI.",
"api_rodin_multiview_to_model": "Sculpt comprehensive 3D models using Rodin's multi-angle reconstruction.",
"api_tripo_text_to_model": "Craft 3D objects from descriptions with Tripo's text-driven modeling.",
"api_tripo_image_to_model": "Generate professional 3D assets from 2D images using Tripo engine.",
"api_tripo_multiview_to_model": "Build 3D models from multiple angles with Tripo's advanced scanner."
},
"LLM API": {
"api_openai_chat": "Engage with OpenAI's advanced language models for intelligent conversations.",
"api_google_gemini": "Experience Google's multimodal AI with Gemini's reasoning capabilities."
},
"Upscaling": {
"hiresfix_latent_workflow": "Upscale images by enhancing quality in latent space.",
"esrgan_example": "Upscale images using ESRGAN models to enhance quality.",
"hiresfix_esrgan_workflow": "Upscale images using ESRGAN models during intermediate generation steps.",
"latent_upscale_different_prompt_model": "Upscale images while changing prompts across generation passes."
},
"ControlNet": {
"controlnet_example": "Generate images guided by scribble reference images using ControlNet.",
"2_pass_pose_worship": "Generate images guided by pose references using ControlNet.",
"depth_controlnet": "Generate images guided by depth information using ControlNet.",
"depth_t2i_adapter": "Generate images guided by depth information using T2I adapter.",
"mixing_controlnets": "Generate images by combining multiple ControlNet models."
},
"Area Composition": {
"area_composition": "Generate images by controlling composition with defined areas.",
"area_composition_square_area_for_subject": "Generate images with consistent subject placement using area composition."
},
"3D": {
"3d_hunyuan3d_image_to_model": "Generate 3D models from single images using Hunyuan3D 2.0.",
"3d_hunyuan3d_multiview_to_model": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV.",
"3d_hunyuan3d_multiview_to_model_turbo": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV Turbo.",
"stable_zero123_example": "Generate 3D views from single images using Stable Zero123."
},
"Audio": {
"audio_stable_audio_example": "Generate audio from text prompts using Stable Audio.",
"audio_ace_step_1_t2a_instrumentals": "Generate instrumental music from text prompts using ACE-Step v1.",
"audio_ace_step_1_t2a_song": "Generate songs with vocals from text prompts using ACE-Step v1, supporting multilingual and style customization.",
"audio_ace_step_1_m2m_editing": "Edit existing songs to change style and lyrics using ACE-Step v1 M2M."
}
},
"template": {
"Basics": {
"default": "Image Generation",
"image2image": "Image to Image",
"lora": "LoRA",
"lora_multiple": "LoRA Multiple",
"inpaint_example": "Inpaint",
"inpaint_model_outpainting": "Outpaint",
"embedding_example": "Embedding",
"gligen_textbox_example": "Gligen Textbox"
},
"Flux": {
"flux_kontext_dev_basic": "Flux Kontext Dev(Basic)",
"flux_kontext_dev_grouped": "Flux Kontext Dev(Grouped)",
"flux_dev_checkpoint_example": "Flux Dev fp8",
"flux_schnell": "Flux Schnell fp8",
"flux_dev_full_text_to_image": "Flux Dev full text to image",
"flux_schnell_full_text_to_image": "Flux Schnell full text to image",
"flux_fill_inpaint_example": "Flux Inpaint",
"flux_fill_outpaint_example": "Flux Outpaint",
"flux_canny_model_example": "Flux Canny Model",
"flux_depth_lora_example": "Flux Depth LoRA",
"flux_redux_model_example": "Flux Redux Model"
},
"Image": {
"image_omnigen2_t2i": "OmniGen2 Text to Image",
"image_omnigen2_image_edit": "OmniGen2 Image Edit",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
"image_chroma_text_to_image": "Chroma text to image",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"hidream_e1_full": "HiDream E1 Full",
"sd3_5_simple_example": "SD3.5 Simple",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Large Depth",
"sd3_5_large_blur": "SD3.5 Large Blur",
"sdxl_simple_example": "SDXL Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxlturbo_example": "SDXL Turbo",
"image_lotus_depth_v1_1": "Lotus Depth"
},
"Video": {
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps",
"video_wan_vace_14B_t2v": "Wan VACE Text to Video",
"video_wan_vace_14B_ref2v": "Wan VACE Reference to Video",
"video_wan_vace_14B_v2v": "Wan VACE Control Video",
"video_wan_vace_outpainting": "Wan VACE Outpainting",
"video_wan_vace_flf2v": "Wan VACE First-Last Frame",
"video_wan_vace_inpainting": "Wan VACE Inpainting",
"video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B",
"video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B",
"text_to_video_wan": "Wan 2.1 Text to Video",
"image_to_video_wan": "Wan 2.1 Image to Video",
"wan2_1_fun_inp": "Wan 2.1 Inpainting",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
"ltxv_text_to_video": "LTXV Text to Video",
"ltxv_image_to_video": "LTXV Image to Video",
"mochi_text_to_video_example": "Mochi Text to Video",
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
"image_to_video": "SVD Image to Video",
"txt_to_image_to_video": "SVD Text to Image to Video"
},
"Image API": {
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext Multiple Image Input",
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]: Text to Image",
"api_luma_photon_i2i": "Luma Photon: Image to Image",
"api_luma_photon_style_ref": "Luma Photon: Style Reference",
"api_recraft_image_gen_with_color_control": "Recraft: Color Control Image Generation",
"api_recraft_image_gen_with_style_control": "Recraft: Style Control Image Generation",
"api_recraft_vector_gen": "Recraft: Vector Generation",
"api_runway_text_to_image": "Runway: Text to Image",
"api_runway_reference_to_image": "Runway: Reference to Image",
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra Text to Image",
"api_stability_ai_i2i": "Stability AI: Image to Image",
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 Text to Image",
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 Image to Image",
"api_ideogram_v3_t2i": "Ideogram V3: Text to Image",
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 Text to Image",
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 Image to Image",
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Inpaint",
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 Multi Inputs",
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 Text to Image",
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Inpaint",
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 Text to Image"
},
"Video API": {
"api_moonvalley_text_to_video": "Moonvalley: Text to Video",
"api_moonvalley_image_to_video": "Moonvalley: Image to Video",
"api_kling_i2v": "Kling: Image to Video",
"api_kling_effects": "Kling: Video Effects",
"api_kling_flf": "Kling: FLF2V",
"api_luma_i2v": "Luma: Image to Video",
"api_luma_t2v": "Luma: Text to Video",
"api_hailuo_minimax_t2v": "MiniMax: Text to Video",
"api_hailuo_minimax_i2v": "MiniMax: Image to Video",
"api_pixverse_i2v": "PixVerse: Image to Video",
"api_pixverse_template_i2v": "PixVerse Templates: Image to Video",
"api_pixverse_t2v": "PixVerse: Text to Video",
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo Image to Video",
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo Image to Video",
"api_runway_first_last_frame": "Runway: First Last Frame to Video",
"api_pika_i2v": "Pika: Image to Video",
"api_pika_scene": "Pika Scenes: Images to Video",
"api_veo2_i2v": "Veo2: Image to Video"
},
"3D API": {
"api_rodin_image_to_model": "Rodin: Image to Model",
"api_rodin_multiview_to_model": "Rodin: Multiview to Model",
"api_tripo_text_to_model": "Tripo: Text to Model",
"api_tripo_image_to_model": "Tripo: Image to Model",
"api_tripo_multiview_to_model": "Tripo: Multiview to Model"
},
"LLM API": {
"api_openai_chat": "OpenAI: Chat",
"api_google_gemini": "Google Gemini: Chat"
},
"Upscaling": {
"hiresfix_latent_workflow": "Upscale",
"esrgan_example": "ESRGAN",
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
},
"ControlNet": {
"controlnet_example": "Scribble ControlNet",
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
"mixing_controlnets": "Mixing ControlNets"
},
"Area Composition": {
"area_composition": "Area Composition",
"area_composition_square_area_for_subject": "Area Composition Square Area for Subject"
},
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MV",
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turbo",
"stable_zero123_example": "Stable Zero123"
},
"Audio": {
"audio_stable_audio_example": "Stable Audio",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Text to Instrumentals Music",
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
}
},
"categories": "Categories",
"resetFilters": "Clear Filters",
"sorting": "Sort by",
@@ -1734,7 +1435,6 @@
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold",
"export": "Export",
"exportModel": "Export Model",
"exportingModel": "Exporting model...",
@@ -1745,8 +1445,7 @@
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"depth": "Depth",
"lineart": "Lineart"
"depth": "Depth"
},
"upDirections": {
"original": "Original"
@@ -1772,7 +1471,10 @@
"exportSettings": "Export Settings",
"modelSettings": "Model Settings"
},
"openIn3DViewer": "Open in 3D Viewer"
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
@@ -1857,12 +1559,7 @@
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Enter the same password again",
"forgotPassword": "Forgot password?",
"passwordResetInstructions": "Enter your email address and we'll send you a link to reset your password.",
"sendResetLink": "Send reset link",
"backToLogin": "Back to login",
"didntReceiveEmail": "Didn't receive an email? Contact us at",
"passwordResetError": "Failed to send password reset email. Please try again.",
"loginButton": "Sign in",
"loginButton": "Log in",
"orContinueWith": "Or continue with",
"loginWithGoogle": "Log in with Google",
"loginWithGithub": "Log in with Github",
@@ -1899,20 +1596,6 @@
"success": "Password Updated",
"successDetail": "Your password has been updated successfully"
},
"errors": {
"auth/invalid-email": "Please enter a valid email address.",
"auth/user-disabled": "This account has been disabled. Please contact support.",
"auth/user-not-found": "No account found with this email. Would you like to create a new account?",
"auth/wrong-password": "The password you entered is incorrect. Please try again.",
"auth/email-already-in-use": "An account with this email already exists. Try signing in instead.",
"auth/weak-password": "Password is too weak. Please use a stronger password with at least 6 characters.",
"auth/too-many-requests": "Too many login attempts. Please wait a moment and try again.",
"auth/operation-not-allowed": "This sign-in method is not currently supported.",
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
"auth/network-request-failed": "Network error. Please check your connection and try again.",
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
},
"deleteAccount": {
"deleteAccount": "Delete Account",
"confirmTitle": "Delete Account",
@@ -2013,8 +1696,7 @@
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeToRun": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes credits"
@@ -2095,128 +1777,6 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"cloudOnboarding": {
"survey": {
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
"steps": {
"familiarity": "How familiar are you with ComfyUI?",
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"questions": {
"familiarity": "How familiar are you with ComfyUI?",
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"options": {
"familiarity": {
"new": "New to ComfyUI (never used it before)",
"starting": "Just getting started (following tutorials)",
"basics": "Comfortable with basics",
"advanced": "Advanced user (custom workflows)",
"expert": "Expert (help others)"
},
"purpose": {
"personal": "Personal projects / hobby",
"community": "Community contributions (nodes, workflows, etc.)",
"client": "Client work (freelance)",
"inhouse": "My own workplace (in-house)",
"research": "Academic research"
},
"industry": {
"film_tv_animation": "Film, TV, & animation",
"gaming": "Gaming",
"marketing": "Marketing & advertising",
"architecture": "Architecture",
"product_design": "Product & graphic design",
"fine_art": "Fine art & illustration",
"software": "Software & technology",
"education": "Education",
"other": "Other",
"otherPlaceholder": "Please specify"
},
"making": {
"images": "Images",
"video": "Video & animation",
"3d": "3D assets",
"audio": "Audio / music",
"custom_nodes": "Custom nodes & workflows"
}
}
},
"forgotPassword": {
"title": "Forgot Password",
"instructions": "Enter your email address and we'll send you a link to reset your password.",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"sendResetLink": "Send reset link",
"backToLogin": "Back to login",
"didntReceiveEmail": "Didn't receive an email? Contact us at",
"passwordResetSent": "Password reset email sent",
"passwordResetError": "Failed to send password reset email. Please try again.",
"emailRequired": "Email is required"
},
"privateBeta": {
"title": "Cloud is currently in private beta",
"desc": "Sign in to join the waitlist. Well notify you when its your turn. Already been notified? Sign in start using Cloud."
},
"start": {
"title": "start creating in seconds",
"desc": "Zero setup required. Works on any device.",
"explain": "Generate multiple outputs at once. Share workflows with ease.",
"learnAboutButton": "Learn about Cloud",
"wantToRun": "Want to run ComfyUI locally instead?",
"download": "Download ComfyUI"
},
"checkingStatus": "Checking your account status...",
"retrying": "Retrying...",
"retry": "Try Again",
"authTimeout": {
"title": "Connection Taking Too Long",
"message": "We're having trouble connecting to ComfyUI Cloud. This could be due to a slow connection or temporary service issue.",
"restart": "Sign Out & Try Again",
"troubleshooting": "Common causes:",
"causes": [
"Corporate firewall or proxy blocking authentication services",
"VPN or network restrictions",
"Browser extensions interfering with requests",
"Regional network limitations",
"Try a different browser or network"
],
"technicalDetails": "Technical Details",
"helpText": "Need help? Contact",
"supportLink": "support"
}
},
"cloudFooter_needHelp": "Need Help?",
"cloudStart_title": "start creating in seconds",
"cloudStart_desc": "Zero setup required. Works on any device.",
"cloudStart_explain": "Generate multiple outputs at once. Share workflows with ease.",
"cloudStart_learnAboutButton": "Learn about Cloud",
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
"cloudStart_download": "Download ComfyUI",
"cloudWaitlist_questionsText": "Questions? Contact us",
"cloudWaitlist_contactLink": "here",
"cloudSorryContactSupport_title": "Sorry, contact support",
"cloudPrivateBeta_title": "Cloud is currently in private beta",
"cloudPrivateBeta_desc": "Sign in to join the waitlist. We'll notify you when it's your turn. Already been notified? Sign in start using Cloud.",
"cloudForgotPassword_title": "Forgot Password",
"cloudForgotPassword_instructions": "Enter your email address and we'll send you a link to reset your password.",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Enter your email",
"cloudForgotPassword_sendResetLink": "Send reset link",
"cloudForgotPassword_backToLogin": "Back to login",
"cloudForgotPassword_didntReceiveEmail": "Didn't receive an email?",
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
"cloudSurvey_steps_industry": "What's your primary industry?",
"cloudSurvey_steps_making": "What do you plan on making?",
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",

View File

@@ -4640,10 +4640,7 @@
},
"height": {
"name": "height"
},
"clear": {},
"upload 3d model": {},
"upload extra resources": {}
}
},
"outputs": {
"0": {
@@ -8805,9 +8802,6 @@
},
"camera_info": {
"name": "camera_info"
},
"image": {
"name": "image"
}
}
},

View File

@@ -1,276 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSessionCookie } from './useSessionCookie'
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Mock api.apiURL
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `https://test-api.com${path}`)
}
}))
// Mock isCloud to always return true for testing
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
// Mock useFirebaseAuthStore
const mockGetAuthHeader = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader
}))
}))
describe('useSessionCookie', () => {
beforeEach(() => {
vi.resetAllMocks()
mockFetch.mockReset()
mockGetAuthHeader.mockReset()
})
describe('createSession', () => {
it('should succeed on first attempt', async () => {
// Mock successful auth header and API response
mockGetAuthHeader.mockResolvedValue({ Authorization: 'Bearer token' })
mockFetch.mockResolvedValue({
ok: true,
status: 200
})
const { createSession } = useSessionCookie()
await createSession()
expect(mockGetAuthHeader).toHaveBeenCalledWith(false) // First attempt with cached token
expect(mockFetch).toHaveBeenCalledWith(
'https://test-api.com/auth/session',
{
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json'
}
}
)
})
it('should retry with fresh token after 401 error and succeed', async () => {
// First attempt: auth header success but API returns 401
// Second attempt: fresh token and API success
mockGetAuthHeader
.mockResolvedValueOnce({ Authorization: 'Bearer cached-token' })
.mockResolvedValueOnce({ Authorization: 'Bearer fresh-token' })
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Token expired' })
})
.mockResolvedValueOnce({
ok: true,
status: 200
})
const { createSession } = useSessionCookie()
await createSession()
expect(mockGetAuthHeader).toHaveBeenCalledTimes(2)
expect(mockGetAuthHeader).toHaveBeenNthCalledWith(1, false) // First attempt with cached token
expect(mockGetAuthHeader).toHaveBeenNthCalledWith(2, true) // Second attempt with forced refresh
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenNthCalledWith(
1,
'https://test-api.com/auth/session',
{
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer cached-token',
'Content-Type': 'application/json'
}
}
)
expect(mockFetch).toHaveBeenNthCalledWith(
2,
'https://test-api.com/auth/session',
{
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer fresh-token',
'Content-Type': 'application/json'
}
}
)
})
it('should fail after all retries with API error', async () => {
// All attempts return auth headers but API always fails
mockGetAuthHeader.mockResolvedValue({ Authorization: 'Bearer token' })
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' })
})
const { createSession } = useSessionCookie()
await expect(createSession()).rejects.toThrow(
'Failed to create session: Server error'
)
expect(mockGetAuthHeader).toHaveBeenCalledTimes(3)
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('should succeed after auth header null then fresh token success', async () => {
// First attempt: no auth header (token timing issue)
// Second attempt: fresh token success
mockGetAuthHeader
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ Authorization: 'Bearer fresh-token' })
mockFetch.mockResolvedValue({
ok: true,
status: 200
})
const { createSession } = useSessionCookie()
await createSession()
expect(mockGetAuthHeader).toHaveBeenCalledTimes(2)
expect(mockGetAuthHeader).toHaveBeenNthCalledWith(1, false) // First attempt with cached token
expect(mockGetAuthHeader).toHaveBeenNthCalledWith(2, true) // Second attempt with forced refresh
expect(mockFetch).toHaveBeenCalledTimes(1) // Only called when auth header is available
expect(mockFetch).toHaveBeenCalledWith(
'https://test-api.com/auth/session',
{
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer fresh-token',
'Content-Type': 'application/json'
}
}
)
})
it('should fail when no auth header is available after all retries', async () => {
// All attempts fail to get auth header (complete auth failure)
mockGetAuthHeader.mockResolvedValue(null)
const { createSession } = useSessionCookie()
await expect(createSession()).rejects.toThrow(
'No auth header available for session creation after retries'
)
expect(mockGetAuthHeader).toHaveBeenCalledTimes(3)
expect(mockFetch).not.toHaveBeenCalled()
})
it('should handle mixed auth header failures and successes', async () => {
// First attempt: no auth header
// Second attempt: auth header but API fails
// Third attempt: auth header and API success
mockGetAuthHeader
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ Authorization: 'Bearer token' })
.mockResolvedValueOnce({ Authorization: 'Bearer fresh-token' })
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Token expired' })
})
.mockResolvedValueOnce({
ok: true,
status: 200
})
const { createSession } = useSessionCookie()
await createSession()
expect(mockGetAuthHeader).toHaveBeenCalledTimes(3)
expect(mockFetch).toHaveBeenCalledTimes(2) // Only called when auth header is available
})
it('should handle JSON parsing errors gracefully', async () => {
mockGetAuthHeader.mockResolvedValue({ Authorization: 'Bearer token' })
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('Invalid JSON'))
})
const { createSession } = useSessionCookie()
await expect(createSession()).rejects.toThrow(
'Failed to create session: Internal Server Error'
)
})
})
describe('deleteSession', () => {
it('should successfully delete session', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200
})
const { deleteSession } = useSessionCookie()
await deleteSession()
expect(mockFetch).toHaveBeenCalledWith(
'https://test-api.com/auth/session',
{
method: 'DELETE',
credentials: 'include'
}
)
})
it('should handle delete session errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'Session not found' })
})
const { deleteSession } = useSessionCookie()
await expect(deleteSession()).rejects.toThrow(
'Failed to delete session: Session not found'
)
})
it('should handle delete session JSON parsing errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('Invalid JSON'))
})
const { deleteSession } = useSessionCookie()
await expect(deleteSession()).rejects.toThrow(
'Failed to delete session: Internal Server Error'
)
})
})
})

View File

@@ -10,52 +10,32 @@ export const useSessionCookie = () => {
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
* Implements retry logic with token refresh for handling timing issues.
*/
const createSession = async (): Promise<void> => {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
// Simple retry with forceRefresh for token timing issues
for (let attempt = 0; attempt < 3; attempt++) {
// First attempt uses cached token, retries force refresh
const authHeader = await authStore.getAuthHeader(attempt > 0)
if (authHeader) {
// Successfully got auth header, proceed with session creation
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
if (response.ok) {
return // Success
}
// API error - continue retry loop if not the last attempt
if (attempt === 2) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
}
}
// Exponential backoff before retry (except for last attempt)
if (attempt < 2) {
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 500))
}
if (!authHeader) {
throw new Error('No auth header available for session creation')
}
// Failed to get auth header after 3 attempts
throw new Error(
'No auth header available for session creation after retries'
)
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
}
}
/**

View File

@@ -1,97 +0,0 @@
<template>
<div class="flex h-full items-center justify-center p-6">
<div class="max-w-[100vw] text-center lg:w-[500px]">
<h2 class="mb-3 text-xl text-text-primary">
{{ $t('cloudOnboarding.authTimeout.title') }}
</h2>
<p class="mb-5 text-muted">
{{ $t('cloudOnboarding.authTimeout.message') }}
</p>
<!-- Troubleshooting Section -->
<div
class="mb-4 rounded bg-surface-700 px-3 py-2 text-left dark-theme:bg-surface-800"
>
<h3 class="mb-2 text-sm font-semibold text-text-primary">
{{ $t('cloudOnboarding.authTimeout.troubleshooting') }}
</h3>
<ul class="space-y-1.5 text-sm text-muted">
<li
v-for="(cause, index) in $tm('cloudOnboarding.authTimeout.causes')"
:key="index"
class="flex gap-2"
>
<span></span>
<span>{{ cause }}</span>
</li>
</ul>
</div>
<!-- Technical Details (Collapsible) -->
<div v-if="errorMessage" class="mb-4 text-left">
<button
class="flex w-full items-center justify-between rounded bg-surface-600 px-4 py-2 text-sm text-muted transition-colors hover:bg-surface-500 dark-theme:bg-surface-700 dark-theme:hover:bg-surface-600"
@click="showTechnicalDetails = !showTechnicalDetails"
>
<span>{{ $t('cloudOnboarding.authTimeout.technicalDetails') }}</span>
<i
:class="[
'pi',
showTechnicalDetails ? 'pi-chevron-up' : 'pi-chevron-down'
]"
></i>
</button>
<div
v-if="showTechnicalDetails"
class="mt-2 rounded bg-surface-800 p-4 font-mono text-xs text-muted break-all dark-theme:bg-surface-900"
>
{{ errorMessage }}
</div>
</div>
<!-- Helpful Links -->
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('cloudOnboarding.authTimeout.supportLink') }}</a
>.
</p>
<div class="flex flex-col gap-3">
<Button
:label="$t('cloudOnboarding.authTimeout.restart')"
class="w-full"
@click="handleRestart"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
interface Props {
errorMessage?: string
}
defineProps<Props>()
const router = useRouter()
const { logout } = useFirebaseAuthActions()
const showTechnicalDetails = ref(false)
const handleRestart = async () => {
await logout()
await router.replace({ name: 'cloud-login' })
}
</script>

View File

@@ -1,126 +0,0 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('cloudForgotPassword_title') }}
</h1>
<p class="my-0 text-base text-muted">
{{ t('cloudForgotPassword_instructions') }}
</p>
</div>
<!-- Form -->
<form class="flex flex-col gap-6" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<label
class="mb-2 text-base font-medium opacity-80"
for="reset-email"
>
{{ t('cloudForgotPassword_emailLabel') }}
</label>
<InputText
id="reset-email"
v-model="email"
type="email"
:placeholder="t('cloudForgotPassword_emailPlaceholder')"
class="h-10"
:invalid="!!errorMessage && !email"
autocomplete="email"
required
/>
<small v-if="errorMessage" class="text-red-500">
{{ errorMessage }}
</small>
</div>
<Message v-if="successMessage" severity="success">
{{ successMessage }}
</Message>
<div class="flex flex-col gap-4">
<Button
type="submit"
:label="t('cloudForgotPassword_sendResetLink')"
:loading="loading"
:disabled="!email || loading"
class="h-10 font-medium text-white"
/>
<Button
type="button"
:label="t('cloudForgotPassword_backToLogin')"
severity="secondary"
class="h-10 bg-[#2d2e32]"
@click="navigateToLogin"
/>
</div>
</form>
<!-- Help text -->
<p class="mt-5 text-sm text-gray-600">
{{ t('cloudForgotPassword_didntReceiveEmail') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
const { t } = useI18n()
const router = useRouter()
const authActions = useFirebaseAuthActions()
const email = ref('')
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const navigateToLogin = () => {
void router.push({ name: 'cloud-login' })
}
const handleSubmit = async () => {
if (!email.value) {
errorMessage.value = t('cloudForgotPassword_emailRequired')
return
}
loading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
// sendPasswordReset is already wrapped and returns a promise
await authActions.sendPasswordReset(email.value)
successMessage.value = t('cloudForgotPassword_passwordResetSent')
// Optionally redirect to login after a delay
setTimeout(() => {
navigateToLogin()
}, 3000)
} catch (error) {
console.error('Password reset error:', error)
errorMessage.value = t('cloudForgotPassword_passwordResetError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
</style>

View File

@@ -1,131 +0,0 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mt-6 mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.login.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const navigateToSignup = () => {
void router.push({ name: 'cloud-signup', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Login Completed',
life: 2000
})
await router.push({ name: 'cloud-user-check' })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
authError.value = ''
if (await authActions.signInWithEmail(values.email, values.password)) {
await onSuccess()
}
}
</script>

View File

@@ -1,177 +0,0 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.signup.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{
t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToLogin"
>{{ t('auth.signup.signIn') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<!-- Terms & Contact -->
<div class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
<p class="mt-2">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const navigateToLogin = () => {
void router.push({ name: 'cloud-login', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Sign up Completed',
life: 2000
})
// Direct redirect to main app - email verification removed
await router.push({ path: '/', query: route.query })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
authError.value = ''
if (await authActions.signUpWithEmail(values.email, values.password)) {
await onSuccess()
}
}
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
useTelemetry()?.trackSignupOpened()
}
userIsInChina.value = await isInChina()
})
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -1,22 +0,0 @@
<template>
<div class="cloud-sorry-contact-support">
<h1>{{ t('cloudSorryContactSupport_title') }}</h1>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.cloud-sorry-contact-support {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: monospace;
font-size: 1.5rem;
}
</style>

View File

@@ -1,387 +0,0 @@
<template>
<div>
<Stepper
value="1"
class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col"
>
<ProgressBar
:value="progressPercent"
:show-value="false"
class="mb-8 h-2"
/>
<StepPanels class="flex flex-1 flex-col p-0">
<StepPanel
v-slot="{ activateCallback }"
value="1"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_familiarity')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in familiarityOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.familiarity"
:input-id="`fam-${opt.value}`"
name="familiarity"
:value="opt.value"
/>
<label
:for="`fam-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex justify-between pt-4">
<span />
<Button
label="Next"
:disabled="!validStep1"
class="h-10 w-full border-none text-white"
@click="goTo(2, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="2"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_purpose')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in purposeOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.useCase"
:input-id="`purpose-${opt.value}`"
name="purpose"
:value="opt.value"
/>
<label
:for="`purpose-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.useCaseOther"
class="w-full"
placeholder="Please specify"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(1, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep2"
class="h-10 flex-1 text-white"
@click="goTo(3, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="3"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_industry')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in industryOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.industry"
:input-id="`industry-${opt.value}`"
name="industry"
:value="opt.value"
/>
<label
:for="`industry-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.industry === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.industryOther"
class="w-full"
placeholder="Please specify"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(2, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep3"
class="h-10 flex-1 border-none text-white"
@click="goTo(4, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="4"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_making')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in makingOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<Checkbox
v-model="surveyData.making"
:input-id="`making-${opt.value}`"
:value="opt.value"
/>
<label
:for="`making-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(3, activateCallback)"
/>
<Button
label="Submit"
:disabled="!validStep4 || isSubmitting"
:loading="isSubmitting"
class="h-10 flex-1 border-none text-white"
@click="onSubmitSurvey"
/>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import ProgressBar from 'primevue/progressbar'
import RadioButton from 'primevue/radiobutton'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
getSurveyCompletedStatus,
submitSurvey
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const router = useRouter()
// Check if survey is already completed on mount
onMounted(async () => {
try {
const surveyCompleted = await getSurveyCompletedStatus()
if (surveyCompleted) {
// User already completed survey, redirect to waitlist
await router.replace({ name: 'cloud-waitlist' })
} else {
// Track survey opened event
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
}
}
} catch (error) {
console.error('Failed to check survey status:', error)
}
})
const activeStep = ref(1)
const totalSteps = 4
const progressPercent = computed(() =>
Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100))
)
const isSubmitting = ref(false)
const surveyData = ref({
familiarity: '',
useCase: '',
useCaseOther: '',
industry: '',
industryOther: '',
making: [] as string[]
})
// Options
const familiarityOptions = [
{ label: 'New to ComfyUI (never used it before)', value: 'new' },
{ label: 'Just getting started (following tutorials)', value: 'starting' },
{ label: 'Comfortable with basics', value: 'basics' },
{ label: 'Advanced user (custom workflows)', value: 'advanced' },
{ label: 'Expert (help others)', value: 'expert' }
]
const purposeOptions = [
{ label: 'Personal projects/hobby', value: 'personal' },
{
label: 'Community contributions (nodes, workflows, etc.)',
value: 'community'
},
{ label: 'Client work (freelance)', value: 'client' },
{ label: 'My own workplace (in-house)', value: 'inhouse' },
{ label: 'Academic research', value: 'research' },
{ label: 'Other', value: 'other' }
]
const industryOptions = [
{ label: 'Film, TV, & animation', value: 'film_tv_animation' },
{ label: 'Gaming', value: 'gaming' },
{ label: 'Marketing & advertising', value: 'marketing' },
{ label: 'Architecture', value: 'architecture' },
{ label: 'Product & graphic design', value: 'product_design' },
{ label: 'Fine art & illustration', value: 'fine_art' },
{ label: 'Software & technology', value: 'software' },
{ label: 'Education', value: 'education' },
{ label: 'Other', value: 'other' }
]
const makingOptions = [
{ label: 'Images', value: 'images' },
{ label: 'Video & animation', value: 'video' },
{ label: '3D assets', value: '3d' },
{ label: 'Audio/music', value: 'audio' },
{ label: 'Custom nodes & workflows', value: 'custom_nodes' }
]
// Validation per step
const validStep1 = computed(() => !!surveyData.value.familiarity)
const validStep2 = computed(() => {
if (!surveyData.value.useCase) return false
if (surveyData.value.useCase === 'other') {
return !!surveyData.value.useCaseOther?.trim()
}
return true
})
const validStep3 = computed(() => {
if (!surveyData.value.industry) return false
if (surveyData.value.industry === 'other') {
return !!surveyData.value.industryOther?.trim()
}
return true
})
const validStep4 = computed(() => surveyData.value.making.length > 0)
const changeActiveStep = (step: number) => {
activeStep.value = step
}
const goTo = (step: number, activate: (val: string | number) => void) => {
// keep Stepper panel and progress bar in sync; Stepper values are strings
changeActiveStep(step)
activate(String(step))
}
// Submit
const onSubmitSurvey = async () => {
try {
isSubmitting.value = true
// prepare payload with consistent structure
const payload = {
familiarity: surveyData.value.familiarity,
useCase:
surveyData.value.useCase === 'other'
? surveyData.value.useCaseOther?.trim() || 'other'
: surveyData.value.useCase,
industry:
surveyData.value.industry === 'other'
? surveyData.value.industryOther?.trim() || 'other'
: surveyData.value.industry,
making: surveyData.value.making
}
await submitSurvey(payload)
// Track survey submitted event with responses
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', {
industry: payload.industry,
useCase: payload.useCase,
familiarity: payload.familiarity,
making: payload.making
})
}
await router.push({ name: 'cloud-user-check' })
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
:deep(.p-progressbar .p-progressbar-value) {
background-color: #f0ff41 !important;
}
:deep(.p-radiobutton-checked .p-radiobutton-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -1,102 +0,0 @@
<template>
<CloudLoginViewSkeleton v-if="skeletonType === 'login'" />
<CloudSurveyViewSkeleton v-else-if="skeletonType === 'survey'" />
<CloudWaitlistViewSkeleton v-else-if="skeletonType === 'waitlist'" />
<div v-else-if="error" class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] p-2 text-center lg:w-96">
<p class="mb-4 text-red-500">{{ errorMessage }}</p>
<Button
:label="
isRetrying
? $t('cloudOnboarding.retrying')
: $t('cloudOnboarding.retry')
"
:loading="isRetrying"
class="w-full"
@click="handleRetry"
/>
</div>
</div>
<div v-else class="flex items-center justify-center">
<ProgressSpinner class="h-8 w-8" />
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import {
getSurveyCompletedStatus,
getUserCloudStatus
} from '@/platform/cloud/onboarding/auth'
import CloudLoginViewSkeleton from './skeletons/CloudLoginViewSkeleton.vue'
import CloudSurveyViewSkeleton from './skeletons/CloudSurveyViewSkeleton.vue'
const router = useRouter()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const skeletonType = ref<'login' | 'survey' | 'waitlist' | 'loading'>('loading')
const {
isLoading,
error,
execute: checkUserStatus
} = useAsyncState(
wrapWithErrorHandlingAsync(async () => {
await nextTick()
const [cloudUserStats, surveyStatus] = await Promise.all([
getUserCloudStatus(),
getSurveyCompletedStatus()
])
// Navigate based on user status
if (!cloudUserStats) {
skeletonType.value = 'login'
await router.replace({ name: 'cloud-login' })
return
}
// Survey is required for all users
if (!surveyStatus) {
skeletonType.value = 'survey'
await router.replace({ name: 'cloud-survey' })
return
}
// User is fully onboarded (active or whitelist check disabled)
window.location.href = '/'
}),
null,
{ resetOnExecute: false }
)
const errorMessage = computed(() => {
if (!error.value) return ''
// Provide user-friendly error messages
const errorStr = error.value.toString().toLowerCase()
if (errorStr.includes('network') || errorStr.includes('fetch')) {
return 'Connection problem. Please check your internet connection.'
}
if (errorStr.includes('timeout')) {
return 'Request timed out. Please try again.'
}
return 'Unable to check account status. Please try again.'
})
const isRetrying = computed(() => isLoading.value && !!error.value)
const handleRetry = async () => {
await checkUserStatus()
}
</script>

View File

@@ -1,33 +0,0 @@
/* ABC ROM Extended — full face mapping */
@font-face {
font-family: 'ABC ROM Extended';
src:
local('ABC ROM Extended Black Italic'),
local('ABCRom BlackItalic'),
url('../fonts/ABCROMExtended-BlackItalic.woff2') format('woff2'),
url('../fonts/ABCROMExtended-BlackItalic.woff') format('woff');
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Prevent browser from synthesizing fake bold/italic which can cause mismatches */
.hero-title,
.font-abcrom {
font-family: 'ABC ROM Extended', sans-serif;
font-synthesis: none; /* no faux bold/italic */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Figma-like hero style */
.hero-title {
font-size: 32px;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
text-shadow: 0 4px 4px rgb(0 0 0 / 0.25);
/* Figma has leading-trim/text-edge which CSS doesn't support; emulate with tight line-height */
line-height: 1.1;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 887 KiB

View File

@@ -1,235 +0,0 @@
import * as Sentry from '@sentry/vue'
import { isEmpty } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
interface UserCloudStatus {
status: 'active'
}
const ONBOARDING_SURVEY_KEY = 'onboarding_survey'
/**
* Helper function to capture API errors with Sentry
*/
function captureApiError(
error: Error,
endpoint: string,
errorType: 'http_error' | 'network_error',
httpStatus?: number,
operation?: string,
extraContext?: Record<string, any>
) {
const tags: Record<string, any> = {
api_endpoint: endpoint,
error_type: errorType
}
if (httpStatus !== undefined) {
tags.http_status = httpStatus
}
if (operation) {
tags.operation = operation
}
const sentryOptions: any = {
tags,
extra: extraContext ? { ...extraContext } : undefined
}
Sentry.captureException(error, sentryOptions)
}
/**
* Helper function to check if error is already handled HTTP error
*/
function isHttpError(error: unknown, errorMessagePrefix: string): boolean {
return error instanceof Error && error.message.startsWith(errorMessagePrefix)
}
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
try {
const response = await api.fetchApi('/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = new Error(`Failed to get user: ${response.statusText}`)
captureApiError(
error,
'/user',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: '/user',
status_code: response.status,
status_text: response.statusText
}
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get user:')) {
captureApiError(error as Error, '/user', 'network_error')
}
throw error
}
}
export async function getSurveyCompletedStatus(): Promise<boolean> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
// Not an error case - survey not completed is a valid state
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
error_type: 'network_error'
},
extra: {
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
},
level: 'warning'
})
return false
}
}
// @ts-expect-error - Unused function kept for future use
async function postSurveyStatus(): Promise<void> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Submitting survey',
level: 'info',
data: {
survey_fields: Object.keys(survey)
}
})
const response = await api.fetchApi('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
})
if (!response.ok) {
const error = new Error(`Failed to submit survey: ${response.statusText}`)
captureApiError(
error,
'/settings',
'http_error',
response.status,
'submit_survey',
{
survey: {
field_count: Object.keys(survey).length,
field_names: Object.keys(survey)
}
}
)
throw error
}
// Log successful survey submission
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey submitted successfully',
level: 'info'
})
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to submit survey:')) {
captureApiError(
error as Error,
'/settings',
'network_error',
undefined,
'submit_survey'
)
}
throw error
}
}

View File

@@ -1,16 +0,0 @@
<template>
<CloudTemplate>
<!-- This will render the nested route components -->
<RouterView />
</CloudTemplate>
<!-- Global Toast for displaying notifications -->
<GlobalToast />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
</script>

View File

@@ -1,9 +0,0 @@
<template>
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
<img
src="/assets/images/comfy-cloud-logo.svg"
alt="Comfy Cloud Logo"
class="h-3/4 max-h-10 w-auto"
/>
</div>
</template>

View File

@@ -1,128 +0,0 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="mb-2 text-base font-medium opacity-80" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<label
class="text-base font-medium opacity-80"
for="cloud-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
</div>
<Password
input-id="cloud-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
}}</small>
<router-link
:to="{ name: 'cloud-forgot-password' }"
class="text-sm font-medium text-muted no-underline"
>
{{ t('auth.login.forgotPassword') }}
</router-link>
</div>
<!-- Auth Error Message -->
<Message v-if="authError" severity="error">
{{ authError }}
</Message>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="mt-4 h-10 font-medium text-white"
/>
</Form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
defineProps<{
authError?: string
}>()
const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'cloud-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -1,80 +0,0 @@
<template>
<div class="flex">
<BaseViewTemplate dark class="flex-1">
<template #header>
<CloudLogo />
</template>
<slot />
<template #footer>
<CloudTemplateFooter />
</template>
</BaseViewTemplate>
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
<!-- Video Background -->
<video
class="absolute inset-0 h-full w-full object-cover"
autoplay
muted
loop
playsinline
:poster="videoPoster"
>
<source :src="videoSrc" type="video/mp4" />
</video>
<div class="absolute inset-0 h-full w-full bg-black/30"></div>
<!-- Optional Overlay for better visual -->
<div
class="absolute inset-0 flex items-center justify-center text-center text-white"
>
<div>
<h1 class="font-abcrom hero-title font-black uppercase italic">
{{ t('cloudStart_title') }}
</h1>
<p class="m-2 text-center text-xl text-white">
{{ t('cloudStart_desc') }}
</p>
<p class="m-0 text-center text-xl text-white">
{{ t('cloudStart_explain') }}
</p>
</div>
</div>
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
<div class="flex items-center justify-end">
<div class="flex items-center gap-3">
<p class="text-md text-white">
{{ t('cloudStart_wantToRun') }}
</p>
<Button
type="button"
class="h-10 bg-black font-bold text-white"
severity="secondary"
@click="handleDownloadClick"
>
{{ t('cloudStart_download') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { t } from '@/i18n'
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const handleDownloadClick = () => {
window.open('https://www.comfy.org/download', '_blank')
}
</script>
<style>
@import '../assets/css/fonts.css';
</style>

View File

@@ -1,32 +0,0 @@
<template>
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudFooter_needHelp') }}
</a>
</footer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -1,72 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: '/cloud',
component: () =>
import('@/platform/cloud/onboarding/components/CloudLayoutView.vue'),
children: [
{
path: 'login',
name: 'cloud-login',
component: () =>
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } = await import(
'@/composables/auth/useCurrentUser'
)
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
// User is already logged in, redirect to user-check
// user-check will handle survey, or main page routing
return next({ name: 'cloud-user-check' })
}
}
next()
}
},
{
path: 'signup',
name: 'cloud-signup',
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue')
},
{
path: 'forgot-password',
name: 'cloud-forgot-password',
component: () =>
import('@/platform/cloud/onboarding/CloudForgotPasswordView.vue')
},
{
path: 'survey',
name: 'cloud-survey',
component: () =>
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
},
{
path: 'user-check',
name: 'cloud-user-check',
component: () =>
import('@/platform/cloud/onboarding/UserCheckView.vue'),
meta: { requiresAuth: true }
},
{
path: 'sorry-contact-support',
name: 'cloud-sorry-contact-support',
component: () =>
import('@/platform/cloud/onboarding/CloudSorryContactSupportView.vue')
},
{
path: 'auth-timeout',
name: 'cloud-auth-timeout',
component: () =>
import('@/platform/cloud/onboarding/CloudAuthTimeoutView.vue'),
props: true
}
]
}
]

View File

@@ -1,47 +0,0 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] lg:w-96">
<div class="rounded-lg bg-[#2d2e32] p-4">
<Skeleton width="60%" height="1.125rem" class="mb-2" />
<Skeleton width="90%" height="1rem" class="mb-2" />
<Skeleton width="80%" height="1rem" />
</div>
<div class="mt-6 mb-8 flex flex-col gap-4">
<Skeleton width="45%" height="1.5rem" class="my-0" />
<div class="flex items-center">
<Skeleton width="25%" height="1rem" class="mr-1" />
<Skeleton width="20%" height="1rem" />
</div>
</div>
<div class="mb-8">
<Skeleton width="20%" height="1rem" class="mb-2" />
<Skeleton width="100%" height="2.5rem" class="mb-4" />
<Skeleton width="25%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" class="mb-6" />
<Skeleton width="80%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" />
</div>
<div class="my-8 flex items-center">
<div class="flex-1 border-t border-gray-300"></div>
<Skeleton width="30%" height="1rem" class="mx-4" />
<div class="flex-1 border-t border-gray-300"></div>
</div>
<div class="flex flex-col gap-6">
<Skeleton width="100%" height="2.5rem" />
<Skeleton width="100%" height="2.5rem" />
</div>
<div class="mt-5">
<Skeleton width="70%" height="0.875rem" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,30 +0,0 @@
<template>
<div>
<div class="flex min-h-[638px] min-w-[320px] flex-col">
<Skeleton width="100%" height="0.5rem" class="mb-8" />
<div class="flex flex-1 flex-col p-0">
<div class="flex min-h-full flex-1 flex-col justify-between">
<div>
<Skeleton width="70%" height="1.75rem" class="mb-8" />
<div class="flex flex-col gap-6">
<div v-for="i in 5" :key="i" class="flex items-center gap-3">
<Skeleton width="1.25rem" height="1.25rem" shape="circle" />
<Skeleton width="85%" height="0.875rem" />
</div>
</div>
</div>
<div class="flex justify-between pt-4">
<span />
<Skeleton width="100%" height="2.5rem" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,11 +1,11 @@
<template>
<Button
v-tooltip.bottom="{
value: $t('subscription.subscribeToRunFull'),
value: $t('subscription.subscribeToRun'),
showDelay: 600
}"
class="subscribe-to-run-button"
:label="buttonLabel"
:label="$t('subscription.subscribeToRun')"
icon="pi pi-lock"
severity="primary"
size="small"
@@ -15,7 +15,6 @@
}"
:pt="{
root: {
class: 'whitespace-nowrap',
style: {
borderColor: 'transparent'
}
@@ -27,25 +26,12 @@
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
const buttonLabel = computed(() =>
isMdOrLarger.value
? t('subscription.subscribeToRunFull')
: t('subscription.subscribeToRun')
)
const { showSubscriptionDialog } = useSubscription()
const handleSubscribeToRun = () => {

View File

@@ -14,6 +14,7 @@ type ServerHealthAlert = {
*/
export type RemoteConfig = {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
}

View File

@@ -6,7 +6,6 @@ import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
import type { Keybinding } from '@/schemas/keyBindingSchema'
import { NodeBadgeMode } from '@/types/nodeSource'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import { breakpointsTailwind } from '@vueuse/core'
/**
* Core settings are essential configuration parameters required for ComfyUI's basic functionality.
@@ -952,7 +951,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Minimap.Visible',
name: 'Display minimap on canvas',
type: 'hidden',
defaultValue: window.innerWidth >= breakpointsTailwind.lg,
defaultValue: true,
versionAdded: '1.25.0'
},
{

View File

@@ -16,27 +16,40 @@
*/
import { isCloud } from '@/platform/distribution/types'
import { CloudAnalyticsProvider } from './providers/cloud/CloudAnalyticsProvider'
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
import type { TelemetryProvider } from './types'
import { TelemetryService } from './services/TelemetryService'
// Singleton instance
let _telemetryProvider: TelemetryProvider | null = null
let _telemetryService: TelemetryService | null = null
/**
* Telemetry factory - conditionally creates provider based on distribution
* Telemetry service factory - conditionally creates service with providers based on distribution
* Returns singleton instance.
*
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
* CRITICAL: This returns null in OSS builds. There is no telemetry service
* for OSS builds and all tracking calls are no-ops.
*/
export function useTelemetry(): TelemetryProvider | null {
if (_telemetryProvider === null) {
export function useTelemetry(): TelemetryService | null {
if (_telemetryService === null) {
// Use distribution check for tree-shaking
if (isCloud) {
_telemetryProvider = new MixpanelTelemetryProvider()
_telemetryService = new TelemetryService()
// Initialize and add both analytics providers
const mixpanelProvider = new MixpanelTelemetryProvider()
const cloudAnalyticsProvider = new CloudAnalyticsProvider()
void mixpanelProvider.initialize().then(() => {
_telemetryService?.addProvider(mixpanelProvider)
})
void cloudAnalyticsProvider.initialize().then(() => {
_telemetryService?.addProvider(cloudAnalyticsProvider)
})
}
// For OSS builds, _telemetryProvider stays null
// For OSS builds, _telemetryService stays null
}
return _telemetryProvider
return _telemetryService
}

View File

@@ -0,0 +1,49 @@
import type {
ExecutionContext,
SurveyResponses,
TemplateMetadata
} from '../types'
/**
* Context types provided by domain stores to telemetry
*/
interface UserContext {
id: string
email?: string
tier?: 'free' | 'pro' | 'enterprise'
}
interface WorkflowContext {
filename?: string
isTemplate: boolean
nodeCount?: number
hasCustomNodes?: boolean
}
interface SubscriptionContext {
isSubscribed: boolean
plan?: string
creditsRemaining?: number
}
/**
* Telemetry hooks interface for dependency inversion.
* Domain stores register these hooks to provide context to telemetry
* without creating circular dependencies.
*/
export interface TelemetryHooks {
// Context providers
getExecutionContext?(): ExecutionContext | null
getCurrentUser?(): UserContext | null
getActiveWorkflow?(): WorkflowContext | null
// Setting providers
getSurveyData?(): SurveyResponses | null
getSubscriptionStatus?(): SubscriptionContext | null
// Template metadata providers
getTemplateMetadata?(filename: string): TemplateMetadata | null
// Feature flag providers
getFeatureFlags?(): Record<string, boolean>
}

View File

@@ -0,0 +1,91 @@
import type {
AuthMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../types'
/**
* Abstract base class for telemetry providers with lifecycle management.
* Concrete providers should extend this class for consistent behavior.
*/
export abstract class TelemetryProviderBase implements TelemetryProvider {
protected isEnabled = true
protected isInitialized = false
/**
* Initialize the provider (e.g., load external libraries)
*/
abstract initialize(): Promise<void>
/**
* Check if the provider is ready to track events
*/
isReady(): boolean {
return this.isEnabled && this.isInitialized
}
setEnabled(enabled: boolean): void {
this.isEnabled = enabled
}
abstract trackAuth(metadata: AuthMetadata): void
abstract trackUserLoggedIn(): void
abstract trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
abstract trackMonthlySubscriptionSucceeded(): void
abstract trackAddApiCreditButtonClicked(): void
abstract trackApiCreditTopupButtonPurchaseClicked(amount: number): void
abstract trackApiCreditTopupSucceeded(): void
abstract trackRunButton(properties: RunButtonProperties): void
abstract startTopupTracking(): void
abstract checkForCompletedTopup(events: any[] | undefined | null): boolean
abstract clearTopupTracking(): void
abstract trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void
abstract trackEmailVerification(
stage: 'opened' | 'requested' | 'completed'
): void
abstract trackTemplate(metadata: TemplateMetadata): void
abstract trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
abstract trackTemplateLibraryClosed(
metadata: TemplateLibraryClosedMetadata
): void
abstract trackWorkflowImported(metadata: WorkflowImportMetadata): void
abstract trackWorkflowOpened(metadata: WorkflowImportMetadata): void
abstract trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
abstract trackTabCount(metadata: TabCountMetadata): void
abstract trackNodeSearch(metadata: NodeSearchMetadata): void
abstract trackNodeSearchResultSelected(
metadata: NodeSearchResultMetadata
): void
abstract trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
abstract trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
abstract trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
abstract trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
abstract trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
abstract trackWorkflowExecution(context?: ExecutionContext): void
abstract trackExecutionError(metadata: ExecutionErrorMetadata): void
abstract trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void
abstract trackSettingChanged(metadata: SettingChangedMetadata): void
abstract trackUiButtonClicked(metadata: UiButtonClickMetadata): void
}

View File

@@ -0,0 +1,210 @@
import { api } from '@/scripts/api'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { TelemetryProviderBase } from '../TelemetryProviderBase'
/**
* Cloud Analytics Provider for server-side event tracking.
* Posts events to the backend's cloud analytics service (ClickHouse) via api.postCloudAnalytics.
* This complements client-side tracking (Mixpanel) with server-side data collection.
*/
export class CloudAnalyticsProvider extends TelemetryProviderBase {
async initialize(): Promise<void> {
this.isInitialized = true
}
private async postEvent(
eventName: TelemetryEventName,
eventData?: any
): Promise<void> {
if (!this.isEnabled || !this.isInitialized) {
return
}
try {
await api.postCloudAnalytics(eventName, eventData || {})
} catch (error) {
console.error('Failed to post cloud analytics event:', error)
}
}
trackAuth(metadata: AuthMetadata): void {
void this.postEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
void this.postEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
void this.postEvent(eventName)
}
trackMonthlySubscriptionSucceeded(): void {
void this.postEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackAddApiCreditButtonClicked(): void {
void this.postEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = { credit_amount: amount }
void this.postEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackApiCreditTopupSucceeded(): void {
void this.postEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(properties: RunButtonProperties): void {
void this.postEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
startTopupTracking(): void {
// Not applicable for server-side tracking
}
checkForCompletedTopup(_events: any[] | undefined | null): boolean {
// Not applicable for server-side tracking
return false
}
clearTopupTracking(): void {
// Not applicable for server-side tracking
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const eventName =
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
void this.postEvent(eventName, responses)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
let eventName: TelemetryEventName
switch (stage) {
case 'opened':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
break
case 'requested':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
break
case 'completed':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
break
}
void this.postEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
void this.postEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
void this.postEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
void this.postEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
void this.postEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(context?: ExecutionContext): void {
void this.postEvent(TelemetryEvents.EXECUTION_START, context)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
void this.postEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
void this.postEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
void this.postEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
void this.postEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
}

View File

@@ -6,18 +6,11 @@ import {
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
@@ -32,7 +25,6 @@ import type {
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
@@ -43,6 +35,7 @@ import type {
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
import { TelemetryProviderBase } from '../TelemetryProviderBase'
interface QueuedEvent {
eventName: TelemetryEventName
@@ -61,50 +54,41 @@ interface QueuedEvent {
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
* 3. Check dist/assets/*.js files contain no tracking code
*/
export class MixpanelTelemetryProvider implements TelemetryProvider {
private isEnabled = true
export class MixpanelTelemetryProvider extends TelemetryProviderBase {
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
constructor() {
async initialize(): Promise<void> {
const token = window.__CONFIG__?.mixpanel_token
if (token) {
try {
// Dynamic import to avoid bundling mixpanel in OSS builds
void import('mixpanel-browser')
.then((mixpanelModule) => {
this.mixpanel = mixpanelModule.default
this.mixpanel.init(token, {
debug: import.meta.env.DEV,
track_pageview: true,
api_host: 'https://mp.comfy.org',
cross_subdomain_cookie: true,
persistence: 'cookie',
loaded: () => {
this.isInitialized = true
this.flushEventQueue() // flush events that were queued while initializing
useCurrentUser().onUserResolved((user) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
}
})
}
})
if (!token) {
this.setEnabled(false)
return
}
try {
const mixpanelModule = await import('mixpanel-browser')
this.mixpanel = mixpanelModule.default
this.mixpanel.init(token, {
debug: import.meta.env.DEV,
track_pageview: true,
api_host: 'https://mp.comfy.org',
cross_subdomain_cookie: true,
persistence: 'cookie',
loaded: () => {
this.isInitialized = true
this.flushEventQueue()
useCurrentUser().onUserResolved((user) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
}
})
.catch((error) => {
console.error('Failed to load Mixpanel:', error)
this.isEnabled = false
})
} catch (error) {
console.error('Failed to initialize Mixpanel:', error)
this.isEnabled = false
}
} else {
console.warn('Mixpanel token not provided in runtime config')
this.isEnabled = false
}
})
} catch (error) {
console.error('Failed to load Mixpanel:', error)
this.setEnabled(false)
}
}
@@ -146,10 +130,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}
trackAuth(metadata: AuthMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
@@ -202,26 +182,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
clearTopupUtil()
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = this.getExecutionContext()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
trigger_source: options?.trigger_source
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
trackRunButton(properties: RunButtonProperties): void {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackSurvey(
@@ -324,14 +286,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
trackWorkflowExecution(context?: ExecutionContext): void {
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
@@ -349,98 +305,4 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
}

View File

@@ -0,0 +1,239 @@
import type {
TelemetryProvider,
AuthMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
ExecutionTriggerSource
} from '../types'
import type { TelemetryHooks } from '../interfaces/TelemetryHooks'
/**
* Central telemetry service that coordinates multiple analytics providers.
* Uses registered hooks to gather context data from application stores
* without creating circular import dependencies.
*/
export class TelemetryService {
private hooks: TelemetryHooks = {}
private providers: TelemetryProvider[] = []
/**
* Register hooks from domain stores to provide context
*/
registerHooks(hooks: Partial<TelemetryHooks>): void {
this.hooks = { ...this.hooks, ...hooks }
}
/**
* Add analytics provider to receive events
*/
addProvider(provider: TelemetryProvider): void {
this.providers.push(provider)
}
trackAuth(metadata: AuthMetadata): void {
this.providers.forEach((provider) => provider.trackAuth(metadata))
}
trackUserLoggedIn(): void {
this.providers.forEach((provider) => provider.trackUserLoggedIn())
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
this.providers.forEach((provider) => provider.trackSubscription(event))
}
trackMonthlySubscriptionSucceeded(): void {
this.providers.forEach((provider) =>
provider.trackMonthlySubscriptionSucceeded()
)
}
trackAddApiCreditButtonClicked(): void {
this.providers.forEach((provider) =>
provider.trackAddApiCreditButtonClicked()
)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.providers.forEach((provider) =>
provider.trackApiCreditTopupButtonPurchaseClicked(amount)
)
}
trackApiCreditTopupSucceeded(): void {
this.providers.forEach((provider) =>
provider.trackApiCreditTopupSucceeded()
)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const context = this.hooks.getExecutionContext?.()
if (!context) return // Don't track if no context available
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: context.is_template ? 'template' : 'custom',
workflow_name: context.workflow_name ?? 'untitled',
custom_node_count: context.custom_node_count,
total_node_count: context.total_node_count,
subgraph_count: context.subgraph_count,
has_api_nodes: context.has_api_nodes,
api_node_names: context.api_node_names,
trigger_source: options?.trigger_source
}
this.providers.forEach((provider) =>
provider.trackRunButton(runButtonProperties)
)
}
startTopupTracking(): void {
this.providers.forEach((provider) => provider.startTopupTracking())
}
checkForCompletedTopup(events: any[] | undefined | null): boolean {
return this.providers.some((provider) =>
provider.checkForCompletedTopup(events)
)
}
clearTopupTracking(): void {
this.providers.forEach((provider) => provider.clearTopupTracking())
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
this.providers.forEach((provider) => provider.trackSurvey(stage, responses))
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.providers.forEach((provider) => provider.trackEmailVerification(stage))
}
trackTemplate(metadata: TemplateMetadata): void {
this.providers.forEach((provider) => provider.trackTemplate(metadata))
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateLibraryOpened(metadata)
)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateLibraryClosed(metadata)
)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.providers.forEach((provider) =>
provider.trackWorkflowImported(metadata)
)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.providers.forEach((provider) => provider.trackWorkflowOpened(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.providers.forEach((provider) =>
provider.trackPageVisibilityChanged(metadata)
)
}
trackTabCount(metadata: TabCountMetadata): void {
this.providers.forEach((provider) => provider.trackTabCount(metadata))
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.providers.forEach((provider) => provider.trackNodeSearch(metadata))
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.providers.forEach((provider) =>
provider.trackNodeSearchResultSelected(metadata)
)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateFilterChanged(metadata)
)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpCenterOpened(metadata)
)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpResourceClicked(metadata)
)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpCenterClosed(metadata)
)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.providers.forEach((provider) =>
provider.trackWorkflowCreated(metadata)
)
}
trackWorkflowExecution(): void {
const context = this.hooks.getExecutionContext?.()
if (!context) return // Don't track if no context available
this.providers.forEach((provider) =>
provider.trackWorkflowExecution(context)
)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.providers.forEach((provider) => provider.trackExecutionError(metadata))
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.providers.forEach((provider) =>
provider.trackExecutionSuccess(metadata)
)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.providers.forEach((provider) => provider.trackSettingChanged(metadata))
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.providers.forEach((provider) =>
provider.trackUiButtonClicked(metadata)
)
}
}

View File

@@ -258,7 +258,6 @@ export interface WorkflowCreatedMetadata {
*/
export interface TelemetryProvider {
// Authentication flow events
trackSignupOpened(): void
trackAuth(metadata: AuthMetadata): void
trackUserLoggedIn(): void
@@ -315,7 +314,7 @@ export interface TelemetryProvider {
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
trackWorkflowExecution(context?: ExecutionContext): void
trackExecutionError(metadata: ExecutionErrorMetadata): void
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void
@@ -335,7 +334,6 @@ export interface TelemetryProvider {
*/
export const TelemetryEvents = {
// Authentication Flow
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_LOGGED_IN: 'app:user_logged_in',

View File

@@ -20,6 +20,11 @@ import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { useDialogService } from '@/services/dialogService'
import { UserFile } from '@/stores/userFileStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
@@ -743,6 +748,137 @@ export const useWorkflowStore = defineStore('workflow', () => {
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}
// Register telemetry hooks for execution context
const telemetryService = useTelemetry()
telemetryService?.registerHooks({
getExecutionContext() {
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
comfyApp.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
const workflow = activeWorkflow.value
if (workflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
workflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(workflow.filename)
const englishMetadata = templatesStore.getEnglishMetadata(
workflow.filename
)
return {
is_template: true,
workflow_name: workflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: workflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
})
return {
activeWorkflow,
attachWorkflow,
isActive,
openWorkflows,
openedWorkflowIndexShift,
openWorkflow,
openWorkflowsInBackground,
isOpen,
isBusy,
closeWorkflow,
createTemporary,
renameWorkflow,
deleteWorkflow,
saveAs,
saveWorkflow,
reorderWorkflows,
workflows,
bookmarkedWorkflows,
persistedWorkflows,
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows,
isSubgraphActive,
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}
}) satisfies () => WorkflowStore
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {

View File

@@ -20,9 +20,7 @@ export const resolveSlotTargetCandidate = (
const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithKey = target
.closest('.lg-slot, .lg-node-widget')
?.querySelector<HTMLElement>('[data-slot-key]')
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null

View File

@@ -61,12 +61,11 @@
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="absolute inset-0 touch-none"
class="absolute inset-0"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@pointercancel="handlePointerCancel"
@wheel="handleWheel"
/>
</div>
@@ -106,7 +105,6 @@ const {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
handleWheel,
setMinimapRef
} = useMinimap()

View File

@@ -244,7 +244,6 @@ export function useMinimap() {
handlePointerDown: interaction.handlePointerDown,
handlePointerMove: interaction.handlePointerMove,
handlePointerUp: interaction.handlePointerUp,
handlePointerCancel: interaction.handlePointerCancel,
handleWheel: interaction.handleWheel,
setMinimapRef,
updateOption

View File

@@ -35,10 +35,6 @@ export function useMinimapInteraction(
const handlePointerDown = (e: PointerEvent) => {
isDragging.value = true
updateContainerRect()
const target = e.currentTarget
if (target instanceof HTMLElement) {
target.setPointerCapture(e.pointerId)
}
handlePointerMove(e)
}
@@ -57,23 +53,10 @@ export function useMinimapInteraction(
centerViewOn(worldX, worldY)
}
const releasePointer = (e?: PointerEvent) => {
const handlePointerUp = () => {
isDragging.value = false
if (!e) return
const target = e.currentTarget
if (
target instanceof HTMLElement &&
target.hasPointerCapture(e.pointerId)
) {
target.releasePointerCapture(e.pointerId)
}
}
const handlePointerUp = releasePointer
const handlePointerCancel = releasePointer
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
@@ -119,7 +102,6 @@ export function useMinimapInteraction(
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
handleWheel
}
}

View File

@@ -19,7 +19,7 @@
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
class="lg-node-widget group flex items-stretch has-[.widget-expands]:flex-1"
class="group flex items-stretch has-[.widget-expands]:flex-1"
>
<!-- Widget Input Slot Dot -->

View File

@@ -301,12 +301,14 @@ export function useSlotLinkInteraction({
hoveredSlotKey = dragContext.lastPointerTargetSlotKey
hoveredNodeId = dragContext.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target
.closest('.lg-slot, .lg-node-widget')
?.querySelector<HTMLElement>('[data-slot-key]')
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const elWithSlot = target.closest<HTMLElement>('[data-slot-key]')
const elWithNode = elWithSlot
? null
: target.closest<HTMLElement>('[data-node-id]')
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
hoveredNodeId = elWithNode?.dataset['nodeId'] ?? null
hoveredNodeId = hoveredSlotKey
? null
: (elWithNode?.dataset['nodeId'] ?? null)
dragContext.lastPointerEventTarget = target
dragContext.lastPointerTargetSlotKey = hoveredSlotKey
dragContext.lastPointerTargetNodeId = hoveredNodeId
@@ -323,8 +325,10 @@ export function useSlotLinkInteraction({
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const slotCandidate = resolveSlotTargetCandidate(target, context)
const nodeCandidate = resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate?.compatible ? slotCandidate : nodeCandidate
const nodeCandidate = slotCandidate
? null
: resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate ?? nodeCandidate
dragContext.lastHoverSlotKey = hoveredSlotKey
dragContext.lastHoverNodeId = hoveredNodeId
@@ -335,8 +339,7 @@ export function useSlotLinkInteraction({
slotCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!slotCandidate.compatible)
}
if (nodeCandidate && !slotCandidate?.compatible) {
} else if (nodeCandidate) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.index,

View File

@@ -5,7 +5,6 @@ import {
createWebHashHistory,
createWebHistory
} from 'vue-router'
import type { RouteLocationNormalized } from 'vue-router'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
@@ -14,20 +13,8 @@ import { useUserStore } from '@/stores/userStore'
import { isElectron } from '@/utils/envUtil'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingCloudRoutes'
const isFileProtocol = window.location.protocol === 'file:'
// Determine base path for the router
// - Electron: always root
// - Web: rely on Vite's BASE_URL (configured via vite.config `base`)
function getBasePath(): string {
if (isElectron()) return '/'
// Vite injects BASE_URL at build/dev time; default to '/'
return import.meta.env?.BASE_URL || '/'
}
const basePath = getBasePath()
const basePath = isElectron() ? '/' : window.location.pathname
const router = createRouter({
history: isFileProtocol
@@ -37,7 +24,6 @@ const router = createRouter({
// we need this base path or assets will incorrectly resolve from 'http://localhost:7801/'
createWebHistory(basePath),
routes: [
...(isCloud ? cloudOnboardingRoutes : []),
{
path: '/',
component: LayoutDefault,
@@ -47,7 +33,6 @@ const router = createRouter({
name: 'GraphView',
component: () => import('@/views/GraphView.vue'),
beforeEnter: async (_to, _from, next) => {
// Then check user store
const userStore = useUserStore()
await userStore.initialize()
if (userStore.needsLogin) {
@@ -76,27 +61,8 @@ const router = createRouter({
})
if (isCloud) {
const PUBLIC_ROUTE_NAMES = new Set([
'cloud-login',
'cloud-signup',
'cloud-forgot-password',
'cloud-sorry-contact-support'
])
const PUBLIC_ROUTE_PATHS = new Set([
'/cloud/login',
'/cloud/signup',
'/cloud/forgot-password',
'/cloud/sorry-contact-support'
])
function isPublicRoute(to: RouteLocationNormalized) {
const name = String(to.name)
if (PUBLIC_ROUTE_NAMES.has(name)) return true
const path = to.path
return PUBLIC_ROUTE_PATHS.has(path)
}
// Global authentication guard
router.beforeEach(async (to, _from, next) => {
router.beforeEach(async (_to, _from, next) => {
const authStore = useFirebaseAuthStore()
// Wait for Firebase auth to initialize
@@ -113,69 +79,16 @@ if (isCloud) {
// Pass authenticated users
const authHeader = await authStore.getAuthHeader()
const isLoggedIn = !!authHeader
// Allow public routes
if (isPublicRoute(to)) {
if (authHeader) {
return next()
}
// Special handling for user-check
// These routes need auth but handle their own routing logic
if (to.name === 'cloud-user-check') {
if (to.meta.requiresAuth && !isLoggedIn) {
return next({ name: 'cloud-login' })
}
return next()
}
// Show sign-in for unauthenticated users
const dialogService = useDialogService()
const loginSuccess = await dialogService.showSignInDialog()
// Prevent redirect loop when coming from user-check
if (_from.name === 'cloud-user-check' && to.path === '/') {
return next()
}
// Check if route requires authentication
if (to.meta.requiresAuth && !isLoggedIn) {
return next({ name: 'cloud-login' })
}
// Handle other protected routes
if (!isLoggedIn) {
// For Electron, use dialog
if (isElectron()) {
const dialogService = useDialogService()
const loginSuccess = await dialogService.showSignInDialog()
return loginSuccess ? next() : next(false)
}
// For web, redirect to login
return next({ name: 'cloud-login' })
}
// User is logged in - check if they need onboarding
// For root path, check actual user status to handle waitlisted users
if (!isElectron() && isLoggedIn && to.path === '/') {
// Import auth functions dynamically to avoid circular dependency
const { getSurveyCompletedStatus } = await import(
'@/platform/cloud/onboarding/auth'
)
try {
// Check user's actual status
const surveyCompleted = await getSurveyCompletedStatus()
// Survey is required for all users regardless of whitelist status
if (!surveyCompleted) {
return next({ name: 'cloud-survey' })
}
} catch (error) {
console.error('Failed to check user status:', error)
// On error, redirect to user-check as fallback
return next({ name: 'cloud-user-check' })
}
}
// User is logged in and accessing protected route
return next()
if (loginSuccess) return next()
return next(false)
})
}

View File

@@ -326,9 +326,8 @@ export class ComfyApi extends EventTarget {
super()
this.user = ''
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
console.log('Running on', this.api_host)
this.initialClientId = sessionStorage.getItem('clientId')
}
@@ -1252,6 +1251,28 @@ export class ComfyApi extends EventTarget {
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
}
/**
* Posts analytics event to cloud analytics service
* @param eventName The name of the analytics event
* @param eventData The event data (any JSON-serializable object)
* @returns Promise resolving to the response
*/
async postCloudAnalytics(
eventName: string,
eventData: any
): Promise<Response> {
return this.fetchApi(this.internalURL('/cloud_analytics'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_name: eventName,
event_data: eventData
})
})
}
}
export const api = new ComfyApi()

View File

@@ -106,12 +106,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
})
const getIdToken = async (
forceRefresh = false
): Promise<string | undefined> => {
const getIdToken = async (): Promise<string | undefined> => {
if (!currentUser.value) return
try {
return await currentUser.value.getIdToken(forceRefresh)
return await currentUser.value.getIdToken()
} catch (error: unknown) {
if (
error instanceof FirebaseError &&
@@ -137,17 +135,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
* 1. Firebase authentication token (if user is logged in)
* 2. API key (if stored in the browser's credential manager)
*
* @param forceRefresh - If true, forces a refresh of the Firebase token
* @returns {Promise<AuthHeader | null>}
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
* - null if neither authentication method is available
*/
const getAuthHeader = async (
forceRefresh = false
): Promise<AuthHeader | null> => {
const getAuthHeader = async (): Promise<AuthHeader | null> => {
// If available, set header with JWT used to identify the user to Firebase service
const token = await getIdToken(forceRefresh)
const token = await getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`

View File

@@ -131,7 +131,7 @@ const gcsRedirectProxyConfig: ProxyOptions = {
}
export default defineConfig({
base: DISTRIBUTION === 'cloud' ? '/' : '',
base: '',
server: {
host: VITE_REMOTE_DEV ? '0.0.0.0' : undefined,
watch: {