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

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_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: {
model: {
"ui:widget": "semanticSearchModel",
},
model_size: {
"ui:widget": "semanticSearchModelSize",
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
},
},

View File

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

View File

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