diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7bb582120b..ec37ca7f17 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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." } } } diff --git a/web/src/components/config-form/LiveFormDataContext.ts b/web/src/components/config-form/LiveFormDataContext.ts new file mode 100644 index 0000000000..10d9a3e82c --- /dev/null +++ b/web/src/components/config-form/LiveFormDataContext.ts @@ -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( + null, +); diff --git a/web/src/components/config-form/section-configs/semantic_search.ts b/web/src/components/config-form/section-configs/semantic_search.ts index 3f9bbfaec1..dde2b75531 100644 --- a/web/src/components/config-form/section-configs/semantic_search.ts +++ b/web/src/components/config-form/section-configs/semantic_search.ts @@ -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" }, }, }, diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 4fbdf76625..b3261a5cc4 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -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({
- 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, - }} - /> + + 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, + }} + /> + {!embedded && ( diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index ae612d9ac7..ebc6b19b35 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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: { diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx new file mode 100644 index 0000000000..4ee0019363 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx @@ -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 ( + + ); + } + + return ; +}