Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ const CREATE_ACTION_VALUE = '__sub-block-create-action__'
*/
type ComboBoxOption =
| string
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
| {
label: string
id: string
icon?: React.ComponentType<{ className?: string }>
/** Left out of the list but still resolves a stored value to its label. */
hidden?: boolean
}

/**
* Props for the ComboBox component
Expand Down Expand Up @@ -141,12 +147,12 @@ export const ComboBox = memo(function ComboBox({
? options({ values: blockValues ?? {} })
: (options ?? EMPTY_OPTIONS)

if (subBlockId === 'model') {
if (subBlockId === 'model' && !config.mediaModelOptions) {
return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}

return opts
}, [options, blockValues, subBlockId, isModelUsable, deploymentShape])
}, [options, blockValues, subBlockId, isModelUsable, deploymentShape, config.mediaModelOptions])

const [selectorSearch, setSelectorSearch] = useState('')
const debouncedSelectorSearch = useDebounce(selectorSearch.trim(), SEARCH_DEBOUNCE_MS)
Expand Down Expand Up @@ -237,7 +243,12 @@ export const ComboBox = memo(function ComboBox({
let opts: ComboBoxOption[] =
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions

if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) {
if (
subBlockId === 'model' &&
!config.mediaModelOptions &&
isDynamic &&
normalizedFetchedOptions.length > 0
) {
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}

Expand Down Expand Up @@ -272,6 +283,7 @@ export const ComboBox = memo(function ComboBox({
createdOption,
subBlockId,
isModelUsable,
config.mediaModelOptions,
])

// Convert options to Combobox format
Expand All @@ -280,7 +292,7 @@ export const ComboBox = memo(function ComboBox({
if (typeof option === 'string') {
return { label: option, value: option }
}
return { label: option.label, value: option.id, icon: option.icon }
return { label: option.label, value: option.id, icon: option.icon, hidden: option.hidden }
})
return createOption ? [createOption, ...mapped] : mapped
}, [evaluatedOptions, createOption])
Expand Down Expand Up @@ -325,8 +337,11 @@ export const ComboBox = memo(function ComboBox({
return undefined
}

if (evaluatedOptions.length > 0) {
return getOptionValue(evaluatedOptions[0])
const firstSelectable = evaluatedOptions.find(
(option) => typeof option === 'string' || !option.hidden
)
if (firstSelectable) {
return getOptionValue(firstSelectable)
}

return undefined
Expand Down Expand Up @@ -356,16 +371,33 @@ export const ComboBox = memo(function ComboBox({
setInputValue(displayValue)
}

/**
* The default in force when the user emptied the input, so the field is not refilled while they
* type. A dependency change that brings a different default (a provider switch) ends it, and so
* does a remount: a required field saved empty gets its default back when reopened.
*/
const userClearedDefaultRef = useRef<string | null>(null)

// Set default value once permissions are loaded
useEffect(() => {
if (isPermissionLoading) return
if (defaultOptionValue === undefined) return

// Only set default when no value exists (initial block add)
if (value === null || value === undefined) {
// Set the default on initial block add, and after a dependency change clears an opted-in field
const clearedByDependency =
config.restoreDefaultWhenCleared &&
value === '' &&
userClearedDefaultRef.current !== defaultOptionValue
if (value === null || value === undefined || clearedByDependency) {
Comment thread
myxamediyar marked this conversation as resolved.
Resolved
Comment thread
myxamediyar marked this conversation as resolved.
Resolved
setStoreValue(defaultOptionValue)
}
}, [value, defaultOptionValue, setStoreValue, isPermissionLoading])
}, [
value,
defaultOptionValue,
setStoreValue,
isPermissionLoading,
config.restoreDefaultWhenCleared,
])

/**
* Handles wheel event for ReactFlow zoom control
Expand Down Expand Up @@ -521,9 +553,10 @@ export const ComboBox = memo(function ComboBox({
? matchedOption
: matchedOption.id
: newValue
userClearedDefaultRef.current = nextValue === '' ? (defaultOptionValue ?? null) : null
setStoreValue(nextValue)
},
[isPreview, evaluatedOptions, setStoreValue]
[isPreview, evaluatedOptions, setStoreValue, defaultOptionValue]
)

return (
Expand Down
10 changes: 7 additions & 3 deletions apps/sim/blocks/blocks/image_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export const ImageGeneratorV2Block: BlockConfig = {
description: 'Generate images',
authMode: AuthMode.ApiKey,
longDescription:
'Generate images using OpenAI GPT Image, Google Nano Banana, or Fal.ai image models.',
'Generate images using OpenAI GPT Image, Google Nano Banana, Higgsfield SOUL and Z-Image, or Fal.ai image models.',
docsLink: 'https://docs.sim.ai/integrations/image_generator',
category: 'blocks',
integrationType: IntegrationType.AI,
Expand All @@ -304,16 +304,20 @@ export const ImageGeneratorV2Block: BlockConfig = {
options: [
{ label: 'OpenAI', id: 'openai' },
{ label: 'Google Gemini', id: 'gemini' },
{ label: 'Higgsfield', id: 'higgsfield' },
{ label: 'Fal.ai (Multi-Model)', id: 'falai' },
],
commandSearchable: true,
value: () => 'falai',
},
...(['openai', 'gemini', 'falai'] as const).map(
...(['openai', 'gemini', 'higgsfield', 'falai'] as const).map(
(provider): SubBlockConfig => ({
id: 'model',
title: 'Model',
type: 'dropdown',
type: 'combobox',
placeholder: 'Type or select a model...',
mediaModelOptions: true,
restoreDefaultWhenCleared: true,
options: (params) =>
imageModels
.filter((model) => !params || model.provider === provider)
Expand Down
34 changes: 30 additions & 4 deletions apps/sim/blocks/blocks/video_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,11 @@ function withDirectVideoCatalog(subBlocks: SubBlockConfig[]): SubBlockConfig[] {
{ id: 'veo', label: 'Google Gemini' },
{ id: 'luma', label: 'Luma' },
{ id: 'minimax', label: 'MiniMax' },
{ id: 'higgsfield', label: 'Higgsfield' },
{ id: 'falai', label: 'Fal.ai (Multi-Model)' },
],
},
...(['runway', 'veo', 'luma', 'minimax'] as const).map(
...(['runway', 'veo', 'luma', 'minimax', 'higgsfield'] as const).map(
(provider): SubBlockConfig => ({
id: 'model',
title: 'Model',
Expand Down Expand Up @@ -1908,25 +1909,46 @@ export const VideoGeneratorV2Block: BlockConfig = {
},
}

/** Lets a user type a model the catalog does not list yet, as well as pick a listed one. */
function withTypedModelFields(subBlocks: SubBlockConfig[]): SubBlockConfig[] {
return subBlocks.map((field) =>
field.id === 'model'
? {
...field,
type: 'combobox',
placeholder: 'Type or select a model...',
mediaModelOptions: true,
restoreDefaultWhenCleared: true,
}
: field
)
}

export const VideoGeneratorV3Block: BlockConfig = {
...VideoGeneratorV2Block,
sunset: undefined,
type: 'video_generator_v3',
name: 'Video Generator',
description: 'Generate videos from text using AI',
longDescription:
'Generate high-quality videos from text prompts using leading AI providers. Supports Runway, Google Gemini, Luma, MiniMax, and Fal.ai multi-model generation with provider-specific durations, aspect ratios, resolutions, prompt optimization, and native audio controls.',
'Generate high-quality videos from text prompts using leading AI providers. Supports Runway, Google Gemini, Luma, MiniMax, Higgsfield, and Fal.ai multi-model generation with provider-specific durations, aspect ratios, resolutions, prompt optimization, and native audio controls.',
docsLink: 'https://docs.sim.ai/integrations/video_generator',
category: 'blocks',
integrationType: IntegrationType.AI,
bgColor: '#181C1E',
icon: VideoIcon,
hideFromToolbar: false,
subBlocks: withDirectVideoCatalog(withFalVideoCatalog(VideoGeneratorV2Block.subBlocks)),
subBlocks: withTypedModelFields(
withDirectVideoCatalog(withFalVideoCatalog(VideoGeneratorV2Block.subBlocks))
),
tools: {
...VideoGeneratorV2Block.tools,
access: [...VideoGeneratorV2Block.tools.access, 'video_higgsfield'],
config: {
tool: (params) => VideoGeneratorV2Block.tools.config?.tool(params) ?? 'video_runway',
tool: (params) =>
params.provider === 'higgsfield'
? 'video_higgsfield'
: (VideoGeneratorV2Block.tools.config?.tool(params) ?? 'video_runway'),
params: (params) => {
if (params.provider === 'falai')
return VideoGeneratorV2Block.tools.config?.params?.(params) ?? params
Expand Down Expand Up @@ -1969,6 +1991,10 @@ export const VideoGeneratorV3Block: BlockConfig = {
},
inputs: {
...VideoGeneratorV2Block.inputs,
provider: {
type: 'string',
description: 'Video generation provider (runway, veo, luma, minimax, higgsfield, falai)',
},
duration: { type: 'number', description: 'Duration for the selected provider model' },
aspectRatio: {
type: 'string',
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,16 @@ export interface SubBlockConfig {
* silently writes and persists a value the user never picked.
*/
emptyIsValid?: boolean
/**
* The `model` options are image or video generation models, not LLMs, so a combobox does not
* run them through the permission group's LLM model filter (a dropdown never did).
*/
mediaModelOptions?: boolean
/**
* Refills a combobox's default when a dependency change clears it, as a dropdown does. Text
* the user deletes themselves stays empty while they edit, so they can type a new value.
*/
restoreDefaultWhenCleared?: boolean
/** Clears a persisted id after its authoritative single-option fetch confirms deletion. */
clearOnMissingOption?: boolean
/**
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/api/contracts/tools/media/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolvedSecretTraceProvenanceSchema, userFileSchema } from '@/lib/api/c
import { toolBooleanSchema } from '@/lib/api/contracts/tools/media/shared'
import { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'

export const videoProviders = ['runway', 'veo', 'luma', 'minimax', 'falai'] as const
export const videoProviders = ['runway', 'veo', 'luma', 'minimax', 'falai', 'higgsfield'] as const
const MISSING_VIDEO_FIELDS_ERROR = 'Missing required fields: provider, apiKey, and prompt'

export const videoToolBodySchema = z
Expand All @@ -15,7 +15,7 @@ export const videoToolBodySchema = z
message: `Invalid provider. Must be one of: ${videoProviders.join(', ')}`,
}),
apiKey: z.string({ error: MISSING_VIDEO_FIELDS_ERROR }).min(1, MISSING_VIDEO_FIELDS_ERROR),
model: z.string().optional(),
model: z.string().trim().optional(),
prompt: z.string({ error: MISSING_VIDEO_FIELDS_ERROR }).min(1, MISSING_VIDEO_FIELDS_ERROR),
duration: z.coerce.number().optional(),
aspectRatio: z.string().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import type {
TokenServiceAccountFields,
TokenServiceAccountValidationResult,
} from '@/lib/credentials/token-service-accounts/server'
import { HIGGSFIELD_API_BASE, higgsfieldAuthHeaders } from '@/lib/media/higgsfield'

/**
* Higgsfield has no identity endpoint, so the key is checked against the documented request
* status route with an ID no account owns: an accepted key gets 404, a rejected one gets 401.
*/
const HIGGSFIELD_VERIFY_URL =
'https://api.higgsfield.ai/requests/00000000-0000-4000-8000-000000000000/status'
const HIGGSFIELD_VERIFY_URL = `${HIGGSFIELD_API_BASE}/requests/00000000-0000-4000-8000-000000000000/status`
const KEY_PATTERN = /^([^:\s]+):[^:\s]+$/

/** Verifies a Higgsfield `KEY_ID:KEY_SECRET` pair. */
Expand All @@ -31,7 +31,7 @@ export async function validateHiggsfieldServiceAccount(
const response = await fetchProvider(
HIGGSFIELD_VERIFY_URL,
{
headers: { Authorization: `Key ${fields.apiToken.trim()}`, Accept: 'application/json' },
headers: higgsfieldAuthHeaders(fields.apiToken),
redirect: 'error',
},
'get_request_status'
Expand Down
7 changes: 3 additions & 4 deletions apps/sim/lib/internal/image/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,13 @@ describe('image operations', () => {
})

it.each(['openai', 'gemini'])(
'does not purchase a fallback for an unknown %s model',
'rejects a malformed typed %s model before any request',
async (provider) => {
mocks.fetch.mockResolvedValueOnce(new Response('Unexpected generation', { status: 502 }))
const response = await executeImageGeneration(
{ ...falInput, provider, model: 'unknown-model' },
{ ...falInput, provider, model: '../unknown model' },
{
userId: 'user-1',
requestId: 'unknown-model',
requestId: 'malformed-model',
}
)

Expand Down
Loading
Loading