diff --git a/FEATURE_FLAGS_EXPLANATION.md b/FEATURE_FLAGS_EXPLANATION.md new file mode 100644 index 000000000..9e6f3599a --- /dev/null +++ b/FEATURE_FLAGS_EXPLANATION.md @@ -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('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 = {} + + // Retrieves a flag value using dot notation + getServerFeature(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('extension.custom.feature', false) + +// Use in template (automatically reactive) +//
New Feature UI
+ +// 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()` 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 + diff --git a/FEATURE_FLAG_PAYLOAD.md b/FEATURE_FLAG_PAYLOAD.md new file mode 100644 index 000000000..421e14321 --- /dev/null +++ b/FEATURE_FLAG_PAYLOAD.md @@ -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 + diff --git a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue index c9e849554..3c02303a0 100644 --- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue +++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue @@ -1,35 +1,42 @@ @@ -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()) diff --git a/src/components/actionbar/ComfyRunButton/ExperimentalRunButton.vue b/src/components/actionbar/ComfyRunButton/ExperimentalRunButton.vue new file mode 100644 index 000000000..32bdd600d --- /dev/null +++ b/src/components/actionbar/ComfyRunButton/ExperimentalRunButton.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/components/actionbar/ComfyRunButton/FeatureFlaggedRunButton.vue b/src/components/actionbar/ComfyRunButton/FeatureFlaggedRunButton.vue new file mode 100644 index 000000000..13caff89c --- /dev/null +++ b/src/components/actionbar/ComfyRunButton/FeatureFlaggedRunButton.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index de8c85912..2fb7ff967 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -1,4 +1,4 @@ -import { computed, reactive, readonly } from 'vue' +import { computed, reactive, readonly, ref } from 'vue' import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' import { api } from '@/scripts/api' @@ -14,6 +14,17 @@ export enum ServerFeatureFlag { ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled' } +/** + * Feature flag variant structure for experiments + */ +export interface FeatureFlagVariant { + variant: string + payload?: Record +} + +// Demo mode: allows manual override for demonstration +const demoOverrides = ref>({}) + /** * Composable for reactive access to server-side feature flags */ @@ -51,7 +62,35 @@ export function useFeatureFlags() { }) const featureFlag = (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)[ + 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),