diff --git a/web/e2e/specs/settings/semantic-search.spec.ts b/web/e2e/specs/settings/semantic-search.spec.ts new file mode 100644 index 0000000000..b7074800a2 --- /dev/null +++ b/web/e2e/specs/settings/semantic-search.spec.ts @@ -0,0 +1,129 @@ +/** + * Semantic Search settings tests -- MEDIUM tier. + * + * Focuses on the model_size field, which is unused when a GenAI embeddings + * provider is selected as the semantic search model. The resolved config always + * reports model_size (it has a schema default of "small"), even when the YAML + * file has no such key. Clearing model_size for a provider used to run + * unconditionally, which falsely marked the section dirty on load and asked the + * backend to delete a key that wasn't in the config file (KeyError: 'model_size'). + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; +import { configFactory } from "../../fixtures/mock-data/config"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_SCHEMA = JSON.parse( + readFileSync( + resolve(__dirname, "../../fixtures/mock-data/config-schema.json"), + "utf-8", + ), +); + +const PROVIDER = "llama_cpp"; +const SETTINGS_URL = "/settings?page=integrationSemanticSearch"; +const NOT_APPLICABLE = "Not applicable for GenAI providers"; +const UNSAVED = "You have unsaved changes"; + +type SemanticSearch = { + enabled?: boolean; + model?: string; + model_size?: string; +}; + +async function installRoutes(page: Page, semanticSearch: SemanticSearch) { + const config = configFactory({ + genai: { [PROVIDER]: { provider: PROVIDER, roles: ["embeddings"] } }, + semantic_search: semanticSearch, + }); + + let lastSavedConfig: unknown = null; + + await page.route("**/api/config/schema.json", (route) => + route.fulfill({ json: CONFIG_SCHEMA }), + ); + await page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: config }); + } + return route.fulfill({ json: { success: true } }); + }); + await page.route("**/api/config/set", async (route) => { + lastSavedConfig = route.request().postDataJSON(); + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: { semantic_search: semanticSearch } }), + ); + + return { capturedConfig: () => lastSavedConfig }; +} + +test.describe("semantic search model_size @medium", () => { + test("a provider with a defaulted model_size is not dirty on load", async ({ + frigateApp, + }) => { + // model_size stays at its schema default ("small"), i.e. it is not present + // in the YAML. This mirrors the reported bug: selecting a GenAI provider and + // returning to the page. + await installRoutes(frigateApp.page, { + enabled: true, + model: PROVIDER, + }); + await frigateApp.goto(SETTINGS_URL); + + // The provider path is active: model_size shows "Not applicable". + await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible(); + + // Give any clearing effect time to fire, then confirm the section stayed + // clean (no phantom unsaved-changes banner, Save disabled). + await frigateApp.page.waitForTimeout(1000); + await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden(); + await expect( + frigateApp.page.getByRole("button", { name: "Save", exact: true }), + ).toBeDisabled(); + }); + + test("switching from a configured non-default model_size clears it", async ({ + frigateApp, + }) => { + // A genuinely configured non-default model_size ("large") can only come from + // the YAML, so switching to a provider must still remove it. + const capture = await installRoutes(frigateApp.page, { + enabled: true, + model: "jinav2", + model_size: "large", + }); + await frigateApp.goto(SETTINGS_URL); + + // Starts clean on a Jina model. + await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden(); + + // Switch the model to the GenAI provider. + await frigateApp.page + .getByRole("combobox", { name: /Semantic search model/ }) + .click(); + await frigateApp.page.getByRole("option", { name: PROVIDER }).click(); + + // The change is now dirty and model_size is no longer applicable. + await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible(); + await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible(); + + await frigateApp.page + .getByRole("button", { name: "Save", exact: true }) + .click(); + + // The saved payload removes model_size (empty string = "remove" key). + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + semantic_search: { model: PROVIDER, model_size: "" }, + }, + }); + }); +}); diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx index 4ee0019363..6e8f383cae 100644 --- a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx @@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) { 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. + // model_size is unused on a GenAI provider. Only clear it (which the backend + // treats as "remove") for a non-default value, which can only come from the + // config file. A defaulted value is indistinguishable from unset in the + // resolved config, so clearing it would falsely dirty the field and delete a + // YAML key that isn't there. Restore the default when returning to a Jina model. 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) { + if (isProvider) { + if (value !== undefined && value !== schemaDefault) { + onChange(undefined); + } + } else if (value === undefined && schemaDefault) { onChange(schemaDefault); } }, [isProvider, value, onChange, schemaDefault]);