add semantic search model size widget

disables model_size select with n/a text when an embeddings genai provider is selected
This commit is contained in:
Josh Hawkins 2026-05-22 12:38:47 -05:00
parent 01df9b15f2
commit 48d7ee4604
6 changed files with 135 additions and 71 deletions

View File

@ -1543,6 +1543,9 @@
"builtIn": "Built-in Models", "builtIn": "Built-in Models",
"genaiProviders": "GenAI Providers" "genaiProviders": "GenAI Providers"
}, },
"semanticSearchModelSize": {
"notApplicable": "Not applicable for GenAI providers"
},
"review": { "review": {
"title": "Review Settings" "title": "Review Settings"
}, },
@ -1820,8 +1823,7 @@
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}." "mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
}, },
"semanticSearch": { "semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended.", "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
"modelSizeIgnoredForProvider": "Model size only applies to the built-in Jina models. This value will be ignored when using a GenAI embedding provider."
} }
} }
} }

View File

@ -0,0 +1,13 @@
import { createContext } from "react";
import type { ConfigSectionData } from "@/types/configForm";
// Mirrors the current section's in-flight form data so widgets can react
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
// Form memoizes SchemaField via deep equality and, in some transitions
// (notably reverting a field to its saved value), can skip re-rendering
// a widget even though the form data it depends on changed. useContext
// re-runs consumers directly on every provider value update, sidestepping
// that.
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
null,
);

View File

@ -29,28 +29,13 @@ const semanticSearch: SectionConfigOverrides = {
ctx.formData?.model === "jinav2" && ctx.formData?.model === "jinav2" &&
ctx.formData?.model_size === "small", ctx.formData?.model_size === "small",
}, },
{
key: "model-size-ignored-for-provider",
field: "model_size",
messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider",
severity: "info",
position: "after",
condition: (ctx) => {
const model = ctx.formData?.model;
return (
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2"
);
},
},
], ],
uiSchema: { uiSchema: {
model: { model: {
"ui:widget": "semanticSearchModel", "ui:widget": "semanticSearchModel",
}, },
model_size: { model_size: {
"ui:widget": "semanticSearchModelSize",
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" }, "ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
}, },
}, },

View File

@ -87,6 +87,7 @@ import type {
import { useConfigMessages } from "@/hooks/use-config-messages"; import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner"; import { ConfigMessageBanner } from "../ConfigMessageBanner";
import { FieldMessagesContext } from "../FieldMessagesContext"; import { FieldMessagesContext } from "../FieldMessagesContext";
import { LiveFormDataContext } from "../LiveFormDataContext";
export interface SectionConfig { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -998,59 +999,63 @@ export function ConfigSection({
<div className="space-y-6"> <div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} /> <ConfigMessageBanner messages={activeMessages} />
<FieldMessagesContext.Provider value={activeFieldMessages}> <FieldMessagesContext.Provider value={activeFieldMessages}>
<ConfigForm <LiveFormDataContext.Provider
key={formKey} value={(currentFormData as ConfigSectionData | null) ?? null}
schema={modifiedSchema} >
formData={currentFormData} <ConfigForm
onChange={handleChange} key={formKey}
onValidationChange={setHasValidationErrors} schema={modifiedSchema}
fieldOrder={sectionConfig.fieldOrder} formData={currentFormData}
fieldGroups={sectionConfig.fieldGroups} onChange={handleChange}
hiddenFields={effectiveHiddenFields} onValidationChange={setHasValidationErrors}
advancedFields={sectionConfig.advancedFields} fieldOrder={sectionConfig.fieldOrder}
liveValidate={sectionConfig.liveValidate} fieldGroups={sectionConfig.fieldGroups}
uiSchema={sectionConfig.uiSchema} hiddenFields={effectiveHiddenFields}
disabled={disabled || isSaving} advancedFields={sectionConfig.advancedFields}
readonly={readonly} liveValidate={sectionConfig.liveValidate}
showSubmit={false} uiSchema={sectionConfig.uiSchema}
i18nNamespace={configNamespace} disabled={disabled || isSaving}
customValidate={customValidate} readonly={readonly}
formContext={{ showSubmit={false}
level: effectiveLevel, i18nNamespace={configNamespace}
cameraName, customValidate={customValidate}
globalValue, formContext={{
cameraValue, level: effectiveLevel,
hasChanges, cameraName,
extraHasChanges, globalValue,
setExtraHasChanges, cameraValue,
overrides: uiOverrides as JsonValue | undefined, hasChanges,
formData: currentFormData as ConfigSectionData, extraHasChanges,
baselineFormData: effectiveBaselineFormData as ConfigSectionData, setExtraHasChanges,
pendingDataBySection, overrides: uiOverrides as JsonValue | undefined,
onPendingDataChange, formData: currentFormData as ConfigSectionData,
onFormDataChange: (data: ConfigSectionData) => handleChange(data), baselineFormData: effectiveBaselineFormData as ConfigSectionData,
// For widgets that need access to full camera config (e.g., zone names) pendingDataBySection,
fullCameraConfig: onPendingDataChange,
effectiveLevel === "camera" && cameraName onFormDataChange: (data: ConfigSectionData) => handleChange(data),
? config?.cameras?.[cameraName] // For widgets that need access to full camera config (e.g., zone names)
: undefined, fullCameraConfig:
fullConfig: config, effectiveLevel === "camera" && cameraName
// When rendering camera-level sections, provide the section path so ? config?.cameras?.[cameraName]
// field templates can look up keys under the `config/cameras` namespace : undefined,
// When using a consolidated global namespace, keys are nested fullConfig: config,
// under the section name (e.g., `audio.label`) so provide the // When rendering camera-level sections, provide the section path so
// section prefix to templates so they can attempt `${section}.${field}` lookups. // field templates can look up keys under the `config/cameras` namespace
sectionI18nPrefix: sectionPath, // When using a consolidated global namespace, keys are nested
t, // under the section name (e.g., `audio.label`) so provide the
renderers: wrappedRenderers, // section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionDocs: sectionConfig.sectionDocs, sectionI18nPrefix: sectionPath,
fieldDocs: sectionConfig.fieldDocs, t,
hiddenFields: effectiveHiddenFields, renderers: wrappedRenderers,
restartRequired: sectionConfig.restartRequired, sectionDocs: sectionConfig.sectionDocs,
requiresRestart, fieldDocs: sectionConfig.fieldDocs,
isProfile: !!profileName, hiddenFields: effectiveHiddenFields,
}} restartRequired: sectionConfig.restartRequired,
/> requiresRestart,
isProfile: !!profileName,
}}
/>
</LiveFormDataContext.Provider>
</FieldMessagesContext.Provider> </FieldMessagesContext.Provider>
{!embedded && ( {!embedded && (

View File

@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget"; import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget"; import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget"; import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget"; import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { FieldTemplate } from "./templates/FieldTemplate"; import { FieldTemplate } from "./templates/FieldTemplate";
@ -86,6 +87,7 @@ export const frigateTheme: FrigateTheme = {
timezoneSelect: TimezoneSelectWidget, timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget, optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget, semanticSearchModel: SemanticSearchModelWidget,
semanticSearchModelSize: SemanticSearchModelSizeWidget,
onvifProfile: OnvifProfileWidget, onvifProfile: OnvifProfileWidget,
}, },
templates: { templates: {

View File

@ -0,0 +1,57 @@
// Disables model_size and shows "N/A" when a GenAI provider is selected.
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
// SchemaField memoization would skip this widget.
import type { WidgetProps } from "@rjsf/utils";
import { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LiveFormDataContext } from "../../LiveFormDataContext";
import { getSizedFieldClassName } from "../utils";
import { SelectWidget } from "./SelectWidget";
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const liveFormData = useContext(LiveFormDataContext);
const model = liveFormData?.model;
const isProvider =
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2";
// Clear model_size while on a provider (buildOverrides converts to ""
// which the backend treats as "remove"). Restore the schema default
// when returning to a Jina model so the field isn't left empty.
const { value, onChange, schema } = props;
const schemaDefault = schema?.default as string | undefined;
useEffect(() => {
if (isProvider && value !== undefined) {
onChange(undefined);
} else if (!isProvider && value === undefined && schemaDefault) {
onChange(schemaDefault);
}
}, [isProvider, value, onChange, schemaDefault]);
if (isProvider) {
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
return (
<Select value="" disabled>
<SelectTrigger className={fieldClassName}>
<SelectValue
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
defaultValue: "Not applicable for GenAI providers",
})}
/>
</SelectTrigger>
<SelectContent />
</Select>
);
}
return <SelectWidget {...props} />;
}