mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
19 Commits
v1.33.10
...
posthog-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d90716662 | ||
|
|
0b0af89321 | ||
|
|
d314172b98 | ||
|
|
6c3408592e | ||
|
|
b6632443dc | ||
|
|
c8a1df3a05 | ||
|
|
39204135ba | ||
|
|
ffa55cb92b | ||
|
|
3856e0deea | ||
|
|
7b589b5502 | ||
|
|
72a2581068 | ||
|
|
22aea29a0d | ||
|
|
637c1995b4 | ||
|
|
550ca0c911 | ||
|
|
896867b03c | ||
|
|
0cd0218946 | ||
|
|
334404aa3b | ||
|
|
31d842217b | ||
|
|
3dd3e26003 |
229
FEATURE_FLAGS_EXPLANATION.md
Normal file
229
FEATURE_FLAGS_EXPLANATION.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Feature Flags System Explanation
|
||||
|
||||
## Overview
|
||||
|
||||
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
```
|
||||
1. Frontend connects via WebSocket
|
||||
2. Frontend sends client feature flags (first message)
|
||||
3. Backend responds with server feature flags
|
||||
4. Frontend stores flags in api.serverFeatureFlags
|
||||
5. Components use useFeatureFlags() to access flags reactively
|
||||
```
|
||||
|
||||
## Core Implementation
|
||||
|
||||
### 1. The `useFeatureFlags` Composable
|
||||
|
||||
**Location:** `src/composables/useFeatureFlags.ts`
|
||||
|
||||
The composable returns two things:
|
||||
|
||||
#### A. Predefined `flags` Object
|
||||
A reactive object with getter properties for commonly-used feature flags:
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// Access predefined flags
|
||||
flags.supportsPreviewMetadata // boolean | undefined
|
||||
flags.maxUploadSize // number | undefined
|
||||
flags.supportsManagerV4 // boolean | undefined
|
||||
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
|
||||
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Uses Vue's `reactive()` to make the object reactive
|
||||
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
|
||||
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
|
||||
- Returns a `readonly()` wrapper to prevent external mutation
|
||||
|
||||
#### B. Generic `featureFlag` Function
|
||||
A function that creates a computed ref for any feature flag path:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref for any flag
|
||||
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
|
||||
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Accepts any string path (supports dot notation for nested values)
|
||||
- Returns a `computed()` ref that automatically updates when flags change
|
||||
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
|
||||
|
||||
### 2. The Underlying API Layer
|
||||
|
||||
**Location:** `src/scripts/api.ts`
|
||||
|
||||
The `ComfyApi` class manages feature flags:
|
||||
|
||||
```typescript
|
||||
class ComfyApi {
|
||||
// Stores flags received from backend
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
|
||||
// Retrieves a flag value using dot notation
|
||||
getServerFeature<T>(featureName: string, defaultValue?: T): T {
|
||||
return get(this.serverFeatureFlags, featureName, defaultValue) as T
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How Flags Are Received:**
|
||||
1. WebSocket connection is established
|
||||
2. Frontend sends client feature flags as first message
|
||||
3. Backend responds with a `feature_flags` message type
|
||||
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
|
||||
|
||||
**The `get` Function:**
|
||||
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
|
||||
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
|
||||
- Returns `defaultValue` if the path doesn't exist
|
||||
|
||||
### 3. Remote Config Integration
|
||||
|
||||
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
|
||||
|
||||
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
|
||||
|
||||
```typescript
|
||||
// Example from modelUploadButtonEnabled
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
|
||||
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
|
||||
)
|
||||
```
|
||||
|
||||
**Why Two Sources?**
|
||||
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
|
||||
- WebSocket flags: Real-time capability negotiation, updated on reconnection
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Pattern 1: Using Predefined Flags
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// In template
|
||||
if (flags.supportsPreviewMetadata) {
|
||||
// Use enhanced preview feature
|
||||
}
|
||||
|
||||
// In script
|
||||
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
|
||||
```
|
||||
|
||||
### Pattern 2: Using Generic featureFlag Function
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
|
||||
// Create a reactive computed ref
|
||||
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
|
||||
|
||||
// Use in template (automatically reactive)
|
||||
// <div v-if="customFeature">New Feature UI</div>
|
||||
|
||||
// Use in script
|
||||
watch(customFeature, (enabled) => {
|
||||
if (enabled) {
|
||||
// Feature was enabled
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 3: Direct API Access (Non-Reactive)
|
||||
|
||||
```typescript
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Direct access (not reactive, use sparingly)
|
||||
if (api.serverSupportsFeature('supports_preview_metadata')) {
|
||||
// Feature is supported
|
||||
}
|
||||
|
||||
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
|
||||
```
|
||||
|
||||
## Reactivity Explained
|
||||
|
||||
The composable is **reactive** because:
|
||||
|
||||
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
|
||||
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
|
||||
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
|
||||
|
||||
## Adding New Feature Flags
|
||||
|
||||
### Step 1: Add to Enum (if it's a core flag)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts
|
||||
export enum ServerFeatureFlag {
|
||||
// ... existing flags
|
||||
MY_NEW_FEATURE = 'my_new_feature'
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add to flags Object (if commonly used)
|
||||
|
||||
```typescript
|
||||
// In useFeatureFlags.ts flags object
|
||||
get myNewFeature() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Use in Components
|
||||
|
||||
```typescript
|
||||
const { flags } = useFeatureFlags()
|
||||
if (flags.myNewFeature) {
|
||||
// Use the feature
|
||||
}
|
||||
```
|
||||
|
||||
**OR** use the generic function without modifying the composable:
|
||||
|
||||
```typescript
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const myFeature = featureFlag('my_new_feature', false)
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Flags are server-driven**: The backend controls which flags are available
|
||||
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
|
||||
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
|
||||
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
|
||||
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
|
||||
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
|
||||
|
||||
## Testing
|
||||
|
||||
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
|
||||
- Mocking `api.getServerFeature()`
|
||||
- Testing reactive behavior
|
||||
- Testing default values
|
||||
- Testing nested paths
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/composables/useFeatureFlags.ts` - The main composable
|
||||
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
|
||||
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
|
||||
- `docs/FEATURE_FLAGS.md` - Full system documentation
|
||||
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests
|
||||
|
||||
196
FEATURE_FLAG_PAYLOAD.md
Normal file
196
FEATURE_FLAG_PAYLOAD.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Feature Flag Payload Shape
|
||||
|
||||
## Feature Flag Key
|
||||
`demo-run-button-experiment`
|
||||
|
||||
## Expected Structure
|
||||
|
||||
The feature flag value should be either:
|
||||
|
||||
### Control (Original Button)
|
||||
- `false`
|
||||
- `null`
|
||||
- `undefined`
|
||||
- Not set
|
||||
|
||||
When any of these values are present, the original SplitButton will be displayed.
|
||||
|
||||
### Experiment (Experimental Button)
|
||||
An object with the following structure:
|
||||
|
||||
```typescript
|
||||
{
|
||||
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
|
||||
payload?: { // Optional: styling and content overrides
|
||||
label?: string, // Button text (default: "Run")
|
||||
icon?: string, // Icon class (default: variant-specific)
|
||||
backgroundColor?: string, // Background color/class (default: variant-specific)
|
||||
textColor?: string, // Text color/class (default: variant-specific)
|
||||
borderRadius?: string, // Border radius class (default: variant-specific)
|
||||
padding?: string // Padding class (default: "px-4 py-2")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Payloads
|
||||
|
||||
### Bold Gradient Variant
|
||||
```json
|
||||
{
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"backgroundColor": "transparent",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animated Variant
|
||||
```json
|
||||
{
|
||||
"variant": "animated",
|
||||
"payload": {
|
||||
"label": "Launch",
|
||||
"icon": "icon-[lucide--rocket]",
|
||||
"backgroundColor": "bg-primary-background",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-full"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Playful Variant
|
||||
```json
|
||||
{
|
||||
"variant": "playful",
|
||||
"payload": {
|
||||
"label": "Go!",
|
||||
"icon": "icon-[lucide--sparkles]",
|
||||
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-2xl"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Variant
|
||||
```json
|
||||
{
|
||||
"variant": "minimal",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--play]",
|
||||
"backgroundColor": "bg-white",
|
||||
"textColor": "text-gray-800",
|
||||
"borderRadius": "rounded-md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Payload Properties
|
||||
|
||||
### `variant` (required)
|
||||
- Type: `string`
|
||||
- Description: The variant name that determines the base styling and behavior
|
||||
- Supported values:
|
||||
- `"bold-gradient"` - Gradient background with animated effect
|
||||
- `"animated"` - Pulsing animation effect
|
||||
- `"playful"` - Sparkle effects with playful styling
|
||||
- `"minimal"` - Clean, minimal design
|
||||
- Any custom variant name (will use default styling)
|
||||
|
||||
### `payload.label` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"Run"` (or variant-specific default)
|
||||
- Description: The text displayed on the button
|
||||
|
||||
### `payload.icon` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific icon
|
||||
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
|
||||
- Examples:
|
||||
- `"icon-[lucide--play]"` - Play icon
|
||||
- `"icon-[lucide--zap]"` - Lightning bolt
|
||||
- `"icon-[lucide--rocket]"` - Rocket
|
||||
- `"icon-[lucide--sparkles]"` - Sparkles
|
||||
|
||||
### `payload.backgroundColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific background
|
||||
- Description: Tailwind CSS class or CSS color value for background
|
||||
- Examples:
|
||||
- `"bg-primary-background"` - Primary background color
|
||||
- `"bg-white"` - White background
|
||||
- `"transparent"` - Transparent (for gradient overlays)
|
||||
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
|
||||
|
||||
### `payload.textColor` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific text color
|
||||
- Description: Tailwind CSS class or CSS color value for text
|
||||
- Examples:
|
||||
- `"white"` - White text (CSS color)
|
||||
- `"text-white"` - White text (Tailwind class)
|
||||
- `"text-gray-800"` - Dark gray text
|
||||
|
||||
### `payload.borderRadius` (optional)
|
||||
- Type: `string`
|
||||
- Default: Variant-specific border radius
|
||||
- Description: Tailwind CSS border radius class
|
||||
- Examples:
|
||||
- `"rounded-md"` - Medium border radius
|
||||
- `"rounded-xl"` - Extra large border radius
|
||||
- `"rounded-full"` - Fully rounded (pill shape)
|
||||
- `"rounded-2xl"` - 2x extra large border radius
|
||||
|
||||
### `payload.padding` (optional)
|
||||
- Type: `string`
|
||||
- Default: `"px-4 py-2"`
|
||||
- Description: Tailwind CSS padding classes
|
||||
- Examples:
|
||||
- `"px-4 py-2"` - Standard padding
|
||||
- `"px-6 py-3"` - Larger padding
|
||||
- `"px-2 py-1"` - Smaller padding
|
||||
|
||||
## Backend Integration
|
||||
|
||||
The backend should send this feature flag via WebSocket in the `feature_flags` message:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": {
|
||||
"variant": "bold-gradient",
|
||||
"payload": {
|
||||
"label": "Run",
|
||||
"icon": "icon-[lucide--zap]",
|
||||
"textColor": "white",
|
||||
"borderRadius": "rounded-xl"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or to show the control (original button):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "feature_flags",
|
||||
"data": {
|
||||
"demo-run-button-experiment": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
|
||||
- The `variant` property is required when the flag is truthy
|
||||
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
|
||||
- The component will automatically handle both formats
|
||||
|
||||
@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const relativePath = path
|
||||
.relative(path.join(projectRoot, 'src'), id)
|
||||
.replace(/\\/g, '/')
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
let shimContent = `// Shim for ${relativePath}\n`
|
||||
|
||||
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
|
||||
const fileKey = relativePath.replace(/\.ts$/, '')
|
||||
const warningMessage = getWarningMessage(fileKey, shimFileName)
|
||||
|
||||
if (warningMessage) {
|
||||
|
||||
24
cloud-loader-dropdown.md
Normal file
24
cloud-loader-dropdown.md
Normal file
@@ -0,0 +1,24 @@
|
||||
Fixes loader dropdown placeholder
|
||||
===============================
|
||||
|
||||
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
|
||||
|
||||
```ts
|
||||
watch(
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
const item = dropdownItems.value.find((item) => item.name === currentValue)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
```
|
||||
|
||||
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesn’t hit this because it still reads from `widget.options.values` immediately.
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<!-- If user has active subscription, show ComfyQueueButton (which already has FeatureFlaggedRunButton) -->
|
||||
<ComfyQueueButton v-if="isActiveSubscription" />
|
||||
|
||||
<!-- If subscription required but not active, wrap SubscribeToRunButton with feature flag -->
|
||||
<FeatureFlaggedRunButton
|
||||
v-else
|
||||
flag-key="demo-run-button-experiment"
|
||||
:on-click="handleSubscribeClick"
|
||||
>
|
||||
<template #control>
|
||||
<!-- SubscribeToRunButton handles its own click (shows subscription dialog) -->
|
||||
<SubscribeToRunButton />
|
||||
</template>
|
||||
</FeatureFlaggedRunButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
// Get subscription status - replace with actual subscription store/composable
|
||||
const isActiveSubscription = computed(() => {
|
||||
// TODO: Replace with actual subscription check
|
||||
// Example: return useSubscriptionStore().isActiveSubscription
|
||||
// For now, this would typically come from a store or composable
|
||||
return false
|
||||
})
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// Handle click for experimental button when SubscribeToRunButton is shown
|
||||
// For experimental variants, trigger queue prompt (same as ComfyQueueButton)
|
||||
// For control, SubscribeToRunButton handles its own click (shows subscription dialog)
|
||||
const handleSubscribeClick = async (e: Event) => {
|
||||
// If this is called from the experimental button, trigger queue prompt
|
||||
// If called from SubscribeToRunButton (control), it will handle its own logic
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.33.10",
|
||||
"version": "1.33.9",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
Binary file not shown.
@@ -1,35 +1,42 @@
|
||||
<template>
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
<FeatureFlaggedRunButton
|
||||
flag-key="demo-run-button-experiment"
|
||||
:on-click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
<template #control>
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
class="comfyui-queue-button"
|
||||
:label="String(activeQueueModeMenuItem?.label ?? '')"
|
||||
severity="primary"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label ?? '')"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
text
|
||||
/>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</template>
|
||||
</SplitButton>
|
||||
</FeatureFlaggedRunButton>
|
||||
<BatchCountEdit />
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +59,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div
|
||||
:class="buttonClasses"
|
||||
:style="buttonStyles"
|
||||
class="experimental-run-button relative overflow-hidden transition-all duration-300"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Animated background for gradient variant -->
|
||||
<div
|
||||
v-if="variantName === 'bold-gradient'"
|
||||
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
|
||||
/>
|
||||
|
||||
<!-- Pulsing animation for animated variant -->
|
||||
<div
|
||||
v-if="variantName === 'animated'"
|
||||
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
|
||||
/>
|
||||
|
||||
<!-- Sparkle effect for playful variant -->
|
||||
<div
|
||||
v-if="variantName === 'playful'"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<i
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="sparkleClasses[i - 1]"
|
||||
class="absolute animate-sparkle text-yellow-300"
|
||||
:style="sparkleStyles[i - 1]"
|
||||
>
|
||||
✨
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<!-- Button content -->
|
||||
<div class="relative z-10 flex items-center justify-center gap-2">
|
||||
<i :class="iconClass" class="text-lg" />
|
||||
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
const props = defineProps<{
|
||||
variant: FeatureFlagVariant
|
||||
onClick: () => void
|
||||
}>()
|
||||
|
||||
const variantName = computed(() => props.variant.variant)
|
||||
const payload = computed(() => props.variant.payload || {})
|
||||
|
||||
// Extract styling from payload with defaults
|
||||
const backgroundColor = computed(
|
||||
() => (payload.value.backgroundColor as string) || getDefaultColor()
|
||||
)
|
||||
const textColor = computed(
|
||||
() => (payload.value.textColor as string) || getDefaultTextColor()
|
||||
)
|
||||
const borderRadius = computed(
|
||||
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
|
||||
)
|
||||
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
|
||||
const label = computed(() => (payload.value.label as string) || 'Run')
|
||||
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
|
||||
|
||||
// Size matching - should match PrimeVue small button size
|
||||
const buttonSize = computed(() => {
|
||||
return 'text-sm' // Match PrimeVue small button
|
||||
})
|
||||
|
||||
function getDefaultColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'transparent' // Gradient overlay handles it
|
||||
case 'animated':
|
||||
return 'bg-primary-background'
|
||||
case 'playful':
|
||||
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
|
||||
case 'minimal':
|
||||
return 'bg-white border-2 border-gray-300'
|
||||
default:
|
||||
return 'bg-primary-background'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultTextColor(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'text-white'
|
||||
case 'animated':
|
||||
return 'text-white'
|
||||
case 'playful':
|
||||
return 'text-white'
|
||||
case 'minimal':
|
||||
return 'text-gray-800'
|
||||
default:
|
||||
return 'text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultBorderRadius(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'rounded-xl'
|
||||
case 'animated':
|
||||
return 'rounded-full'
|
||||
case 'playful':
|
||||
return 'rounded-2xl'
|
||||
case 'minimal':
|
||||
return 'rounded-md'
|
||||
default:
|
||||
return 'rounded-lg'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultIcon(): string {
|
||||
switch (variantName.value) {
|
||||
case 'bold-gradient':
|
||||
return 'icon-[lucide--zap]'
|
||||
case 'animated':
|
||||
return 'icon-[lucide--rocket]'
|
||||
case 'playful':
|
||||
return 'icon-[lucide--sparkles]'
|
||||
case 'minimal':
|
||||
return 'icon-[lucide--play]'
|
||||
default:
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const base = [
|
||||
'cursor-pointer',
|
||||
'select-none',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
padding.value,
|
||||
borderRadius.value,
|
||||
'shadow-lg',
|
||||
'hover:scale-105',
|
||||
'active:scale-95',
|
||||
'transition-transform',
|
||||
buttonSize.value
|
||||
]
|
||||
|
||||
// Add variant-specific classes
|
||||
if (variantName.value === 'bold-gradient') {
|
||||
base.push('text-white', 'font-bold')
|
||||
} else if (variantName.value === 'animated') {
|
||||
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
|
||||
} else if (variantName.value === 'playful') {
|
||||
base.push('text-white', 'font-bold', 'hover:rotate-1')
|
||||
} else if (variantName.value === 'minimal') {
|
||||
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
|
||||
} else {
|
||||
base.push(backgroundColor.value, textColor.value)
|
||||
}
|
||||
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const buttonStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
|
||||
// Apply custom styles from payload
|
||||
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
|
||||
styles.backgroundColor = backgroundColor.value
|
||||
}
|
||||
if (payload.value.textColor) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
|
||||
styles.borderRadius = borderRadius.value
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
const iconClass = computed(() => icon.value)
|
||||
|
||||
// Text color handling - can be a CSS class or a color value
|
||||
const labelClasses = computed(() => {
|
||||
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
|
||||
if (textColor.value.startsWith('text-')) {
|
||||
return textColor.value
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const labelStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
// If textColor is not a Tailwind class, use it as a color value
|
||||
if (!textColor.value.startsWith('text-')) {
|
||||
styles.color = textColor.value
|
||||
}
|
||||
return styles
|
||||
})
|
||||
|
||||
// Sparkle animation positions for playful variant
|
||||
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
|
||||
|
||||
const sparkleStyles = [
|
||||
{ animationDelay: '0s', animationDuration: '2s' },
|
||||
{ animationDelay: '0.5s', animationDuration: '2.5s' },
|
||||
{ animationDelay: '1s', animationDuration: '3s' }
|
||||
]
|
||||
|
||||
function handleClick() {
|
||||
props.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes gradient {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-sparkle {
|
||||
animation: sparkle 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="feature-flagged-run-button">
|
||||
<!-- Control: Original button -->
|
||||
<slot v-if="!isExperimentActive" name="control" />
|
||||
|
||||
<!-- Experiment: Experimental button -->
|
||||
<ExperimentalRunButton
|
||||
v-else-if="variant"
|
||||
:variant="variant"
|
||||
:on-click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
|
||||
|
||||
import ExperimentalRunButton from './ExperimentalRunButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
flagKey?: string
|
||||
onClick: (e: Event) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
|
||||
|
||||
const { featureFlag } = useFeatureFlags()
|
||||
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
|
||||
flagKey.value,
|
||||
false
|
||||
)
|
||||
|
||||
const variant = computed<FeatureFlagVariant | null>(() => {
|
||||
const value = flagValue.value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'variant' in value &&
|
||||
typeof (value as FeatureFlagVariant).variant === 'string'
|
||||
) {
|
||||
return value as FeatureFlagVariant
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isExperimentActive = computed(() => {
|
||||
return variant.value !== null
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
// Create a synthetic event for the onClick handler
|
||||
const syntheticEvent = new Event('click') as Event
|
||||
props.onClick(syntheticEvent)
|
||||
}
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
@@ -263,7 +264,8 @@ export function useJobList() {
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
showAddedHint,
|
||||
isCloud
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
@@ -9,9 +10,21 @@ export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag variant structure for experiments
|
||||
*/
|
||||
export interface FeatureFlagVariant {
|
||||
variant: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Demo mode: allows manual override for demonstration
|
||||
const demoOverrides = ref<Record<string, unknown>>({})
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -27,15 +40,57 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.asset_update_options_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
||||
computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
computed(() => {
|
||||
// Check demo overrides first
|
||||
if (demoOverrides.value[featurePath] !== undefined) {
|
||||
return demoOverrides.value[featurePath] as T
|
||||
}
|
||||
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
|
||||
const remoteConfigKey = featurePath.replace(/-/g, '_')
|
||||
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
|
||||
remoteConfigKey
|
||||
]
|
||||
if (remoteValue !== undefined) {
|
||||
return remoteValue as T
|
||||
}
|
||||
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
|
||||
const wsValue = api.getServerFeature(featurePath, undefined)
|
||||
if (wsValue !== undefined) {
|
||||
return wsValue as T
|
||||
}
|
||||
// Try underscore version for WebSocket flags
|
||||
const wsValueUnderscore = api.getServerFeature(
|
||||
featurePath.replace(/-/g, '_'),
|
||||
undefined
|
||||
)
|
||||
if (wsValueUnderscore !== undefined) {
|
||||
return wsValueUnderscore as T
|
||||
}
|
||||
// Return default if nothing found
|
||||
return defaultValue as T
|
||||
})
|
||||
|
||||
return {
|
||||
flags: readonly(flags),
|
||||
|
||||
@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.RemoteConfig',
|
||||
|
||||
setup: async () => {
|
||||
// Poll for config updates every 30 seconds
|
||||
setInterval(() => void loadRemoteConfig(), 30000)
|
||||
// Poll for config updates every 10 minutes
|
||||
setInterval(() => void loadRemoteConfig(), 600_000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,8 +2,7 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
calculateInputSlotPos,
|
||||
calculateInputSlotPosFromSlot,
|
||||
calculateOutputSlotPos,
|
||||
getSlotPosition
|
||||
calculateOutputSlotPos
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -3355,16 +3354,6 @@ export class LGraphNode
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
|
||||
* @param slotIndex The slot index
|
||||
* @param isInput Whether this is an input slot
|
||||
* @returns Position of the slot center in graph coordinates
|
||||
*/
|
||||
getSlotPosition(slotIndex: number, isInput: boolean): Point {
|
||||
return getSlotPosition(this, slotIndex, isInput)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
snapToGrid(snapTo: number): boolean {
|
||||
return this.pinned ? false : snapPoint(this.pos, snapTo)
|
||||
|
||||
@@ -985,6 +985,7 @@
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
"openWorkflowNewTab": "Open workflow in new tab",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TelemetryEventName } from '@/platform/telemetry/types'
|
||||
|
||||
/**
|
||||
* Server health alert configuration from the backend
|
||||
*/
|
||||
@@ -31,4 +33,7 @@ export type RemoteConfig = {
|
||||
comfy_api_base_url?: string
|
||||
comfy_platform_base_url?: string
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_update_options_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
|
||||
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
|
||||
* Supports URLs like:
|
||||
* - /?template=flux_simple (loads with default source)
|
||||
* - /?template=flux_simple&source=custom (loads from custom source)
|
||||
* - /?template=flux_simple&mode=linear (loads template in linear mode)
|
||||
*
|
||||
* Input validation:
|
||||
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
|
||||
* - Invalid formats are rejected with console warnings
|
||||
*/
|
||||
export function useTemplateUrlLoader() {
|
||||
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const templateWorkflows = useTemplateWorkflows()
|
||||
const canvasStore = useCanvasStore()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const SUPPORTED_MODES = ['linear'] as const
|
||||
type SupportedMode = (typeof SUPPORTED_MODES)[number]
|
||||
|
||||
/**
|
||||
* Validates parameter format to prevent path traversal and injection attacks
|
||||
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template and source parameters from URL
|
||||
* Type guard to check if a value is a supported mode
|
||||
*/
|
||||
const isSupportedMode = (mode: string): mode is SupportedMode => {
|
||||
return SUPPORTED_MODES.includes(mode as SupportedMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes template, source, and mode parameters from URL
|
||||
*/
|
||||
const cleanupUrlParams = () => {
|
||||
const newQuery = { ...route.query }
|
||||
delete newQuery.template
|
||||
delete newQuery.source
|
||||
delete newQuery.mode
|
||||
void router.replace({ query: newQuery })
|
||||
}
|
||||
|
||||
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
|
||||
return
|
||||
}
|
||||
|
||||
const modeParam = route.query.mode as string | undefined
|
||||
|
||||
if (
|
||||
modeParam &&
|
||||
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
|
||||
) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (modeParam && !isSupportedMode(modeParam)) {
|
||||
console.warn(
|
||||
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -213,12 +213,13 @@ const acceptTypes = computed(() => {
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
[modelValue, dropdownItems],
|
||||
([currentValue, _dropdownItems]) => {
|
||||
if (currentValue === undefined) {
|
||||
selectedSet.value.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const item = dropdownItems.value.find((item) => item.name === currentValue)
|
||||
if (item) {
|
||||
selectedSet.value.clear()
|
||||
|
||||
@@ -80,7 +80,7 @@ const router = createRouter({
|
||||
installPreservedQueryTracker(router, [
|
||||
{
|
||||
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
||||
keys: ['template', 'source']
|
||||
keys: ['template', 'source', 'mode']
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
type BuildJobDisplayCtx = {
|
||||
@@ -11,6 +12,8 @@ type BuildJobDisplayCtx = {
|
||||
currentNodePercent?: number
|
||||
currentNodeName?: string
|
||||
showAddedHint?: boolean
|
||||
/** Whether the app is running in cloud distribution */
|
||||
isCloud?: boolean
|
||||
}
|
||||
|
||||
type JobDisplay = {
|
||||
@@ -122,13 +125,20 @@ export const buildJobDisplay = (
|
||||
const time = task.executionTimeInSeconds
|
||||
const preview = task.previewOutput
|
||||
const iconImageUrl = preview && preview.isImage ? preview.url : undefined
|
||||
|
||||
// Cloud shows "Completed in Xh Ym Zs", non-cloud shows filename
|
||||
const primary = ctx.isCloud
|
||||
? ctx.t('queue.completedIn', {
|
||||
duration: formatDuration(task.executionTime ?? 0)
|
||||
})
|
||||
: preview?.filename && preview.filename.length
|
||||
? preview.filename
|
||||
: buildTitle(task, ctx.t)
|
||||
|
||||
return {
|
||||
iconName: iconForJobState(state),
|
||||
iconImageUrl,
|
||||
primary:
|
||||
preview?.filename && preview.filename.length
|
||||
? preview.filename
|
||||
: buildTitle(task, ctx.t),
|
||||
primary,
|
||||
secondary: time !== undefined ? `${time.toFixed(2)}s` : '',
|
||||
showClear: false
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/
|
||||
* Tests the behavior of loading templates via URL query parameters:
|
||||
* - ?template=flux_simple loads the template
|
||||
* - ?template=flux_simple&source=custom loads from custom source
|
||||
* - ?template=flux_simple&mode=linear loads template in linear mode
|
||||
* - Invalid template shows error toast
|
||||
* - Input validation for template and source parameters
|
||||
* - Input validation for template, source, and mode parameters
|
||||
*/
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
@@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock canvas store
|
||||
const mockCanvasStore = {
|
||||
linearMode: false
|
||||
}
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
describe('useTemplateUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockCanvasStore.linearMode = false
|
||||
})
|
||||
|
||||
it('does not load template when no query param present', () => {
|
||||
@@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
source: 'custom',
|
||||
mode: 'linear',
|
||||
other: 'param'
|
||||
}
|
||||
|
||||
@@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => {
|
||||
query: { other: 'param' }
|
||||
})
|
||||
})
|
||||
|
||||
it('sets linear mode when mode=linear and template loads successfully', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('does not set linear mode when template loading fails', async () => {
|
||||
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
|
||||
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('does not set linear mode when mode parameter is not linear', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid mode parameter with special characters', () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
void loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles array mode params correctly', () => {
|
||||
// Vue Router can return string[] for duplicate params
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
mode: ['linear', 'graph'] as any
|
||||
}
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
void loadTemplateFromUrl()
|
||||
|
||||
// Should not load when mode param is an array
|
||||
expect(mockLoadTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns about unsupported mode values but continues loading', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
|
||||
)
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('accepts supported mode parameter: linear', async () => {
|
||||
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid format but warns about unsupported modes', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']
|
||||
|
||||
for (const mode of unsupportedModes) {
|
||||
vi.clearAllMocks()
|
||||
consoleSpy.mockClear()
|
||||
mockCanvasStore.linearMode = false
|
||||
mockQueryParams = { template: 'flux_simple', mode }
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
|
||||
)
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
|
||||
'flux_simple',
|
||||
'default'
|
||||
)
|
||||
expect(mockCanvasStore.linearMode).toBe(false)
|
||||
}
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user