diff --git a/integration_tests/pages/ProjectListPage.ts b/integration_tests/pages/ProjectListPage.ts index c1611851..92cbc88f 100644 --- a/integration_tests/pages/ProjectListPage.ts +++ b/integration_tests/pages/ProjectListPage.ts @@ -20,6 +20,13 @@ export class ProjectListPage extends BasePage { // the rendering-noise filter: part of each library's config section noiseFilter = this.dialog.getByLabel("Ignore rendering noise"); noiseThreshold = this.dialog.locator("#blurAmplitudeThreshold"); + // DINOv2's second look, offered under the filter while it is on + dinov2Check = this.dialog.getByLabel("Check with DINOv2"); + // scrolled to before the switch is used: scrolling to its input, which + // overhangs the switch, shifts the switch inside its clipped box + dinov2CheckLabel = this.dialog.getByText("Check with DINOv2"); + dinov2Threshold = this.dialog.locator("#dinov2Threshold"); + dinov2Status = this.dialog.locator("#dinov2Status"); // the engine's own fields, below the library select vlmProvider = this.dialog.getByRole("combobox", { name: "VLM Provider" }); diff --git a/integration_tests/test/projectList.spec.ts b/integration_tests/test/projectList.spec.ts index 88cd9185..8887095e 100644 --- a/integration_tests/test/projectList.spec.ts +++ b/integration_tests/test/projectList.spec.ts @@ -10,6 +10,7 @@ import { ImageComparison } from "~client/types/imageComparison"; import { API_URL, mockDeleteProject, + mockDinov2Status, mockGetProjects, mockSaveProject, } from "utils/mocks"; @@ -327,3 +328,64 @@ test("opens a project with the filter on as it was saved", async ({ await expect(projectListPage.noiseFilter).toBeChecked(); await expect(projectListPage.noiseThreshold).toHaveValue("42"); }); + +test.describe("DINOv2 check", () => { + test("is saved with its threshold where the server has the service", async ({ + projectListPage, + page, + }) => { + await mockDinov2Status(page, { configured: true, available: true }); + await mockSaveProject(page); + + await projectListPage.editBtn.click(); + await projectListPage.noiseFilter.check(); + await projectListPage.dinov2Check.check(); + // the backend's default, until changed + await expect(projectListPage.dinov2Threshold).toHaveValue("0.15"); + await projectListPage.dinov2Threshold.fill("0.2"); + + const [request] = await Promise.all([ + waitForSave(page, "PUT"), + projectListPage.modal.confirmBtn.click(), + ]); + + expect(savedConfig(request)).toEqual({ + ...JSON.parse(PIXELMATCH_DEFAULT_CONFIG), + blurAmplitudeFilter: true, + dinov2Check: true, + dinov2Threshold: 0.2, + }); + }); + + // with no service, the backend would send every run the filter passes to + // review + test("cannot be turned on where the server has no service", async ({ + projectListPage, + page, + }) => { + await mockDinov2Status(page, { configured: false, available: false }); + + await projectListPage.editBtn.click(); + await projectListPage.noiseFilter.check(); + + await expect(projectListPage.dinov2Check).toBeDisabled(); + await expect(projectListPage.dinov2Status).toHaveText( + "Not available: this server has no DINOv2 service (DINOV2_URL).", + ); + }); + + test("warns when the service is set up but not answering", async ({ + projectListPage, + page, + }) => { + await mockDinov2Status(page, { configured: true, available: false }); + + await projectListPage.editBtn.click(); + await projectListPage.noiseFilter.check(); + + await expect(projectListPage.dinov2Check).toBeEnabled(); + await expect(projectListPage.dinov2Status).toContainText( + "The DINOv2 service is not answering right now", + ); + }); +}); diff --git a/integration_tests/test/screens.spec.ts b/integration_tests/test/screens.spec.ts index de9789a5..02ae10b9 100644 --- a/integration_tests/test/screens.spec.ts +++ b/integration_tests/test/screens.spec.ts @@ -30,6 +30,7 @@ import { mockGetUsers, mockImage, mockMatchingVariations, + mockDinov2Status, mockOllamaModels, mockTestRun, mockUpdateTestRun, @@ -250,6 +251,41 @@ const VIEWS: [name: string, body: Body][] = [ }, ], + [ + "edit project dialog with the DINOv2 check on", + async ({ projectListPage, shot }) => { + await projectListPage.editBtn.click(); + await projectListPage.noiseFilter.check(); + await projectListPage.dinov2CheckLabel.scrollIntoViewIfNeeded(); + await projectListPage.dinov2Check.check(); + // take the focus, and its tooltip, off the switch + await projectListPage.dialog.getByRole("heading").first().click(); + await projectListPage.dinov2Threshold.scrollIntoViewIfNeeded(); + + await expect(projectListPage.dinov2Threshold).toHaveValue("0.15"); + await expect(projectListPage.page.getByRole("tooltip")).toHaveCount(0); + await shot("edit-project-dinov2-check"); + }, + ], + + [ + "edit project dialog on a server without DINOv2", + async ({ projectListPage, shot }) => { + await mockDinov2Status(projectListPage.page, { + configured: false, + available: false, + }); + await projectListPage.editBtn.click(); + await projectListPage.noiseFilter.check(); + await projectListPage.dialog.getByRole("heading").first().click(); + await projectListPage.dinov2Status.scrollIntoViewIfNeeded(); + + await expect(projectListPage.dinov2Check).toBeDisabled(); + await expect(projectListPage.page.getByRole("tooltip")).toHaveCount(0); + await shot("edit-project-no-dinov2"); + }, + ], + [ "delete project confirmation", async ({ projectListPage, shot }) => { @@ -556,6 +592,7 @@ for (const scheme of SCHEMES) { ]); await mockGetUsers(page, [TEST_USER, EDITOR_USER, GUEST_USER]); await mockOllamaModels(page, ["gemma3:12b", "llava:13b"]); + await mockDinov2Status(page, { configured: true, available: true }); await mockImages(page); }); diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-dark.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-dark.png new file mode 100644 index 00000000..3d827993 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-light.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-light.png new file mode 100644 index 00000000..e33940e1 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-dinov2-check-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-dark.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-dark.png new file mode 100644 index 00000000..f35fdc41 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-light.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-light.png new file mode 100644 index 00000000..2915bce9 Binary files /dev/null and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-no-dinov2-light.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-dark.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-dark.png index fca66c59..940c28c1 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-dark.png and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-dark.png differ diff --git a/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-light.png b/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-light.png index dd2e658f..680764b5 100644 Binary files a/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-light.png and b/integration_tests/test/screens.spec.ts-snapshots/edit-project-noise-filter-light.png differ diff --git a/integration_tests/utils/mocks.ts b/integration_tests/utils/mocks.ts index e892a969..1755d4ed 100644 --- a/integration_tests/utils/mocks.ts +++ b/integration_tests/utils/mocks.ts @@ -230,6 +230,16 @@ export const mockOllamaModels = (page: Page, names: string[]) => { ); }; +// whether the backend has a DINOv2 service set up, and whether it answers +export const mockDinov2Status = ( + page: Page, + status: { configured: boolean; available: boolean }, +) => { + return page.route(`${API_URL}/dinov2/status`, (route) => + route.fulfill({ body: JSON.stringify(status) }), + ); +}; + // saving or applying ignore areas, and the comment, answer with nothing the // dialog reads export const mockUpdateIgnoreAreas = (page: Page) => { diff --git a/src/_helpers/useDinov2Status.ts b/src/_helpers/useDinov2Status.ts new file mode 100644 index 00000000..0086f42c --- /dev/null +++ b/src/_helpers/useDinov2Status.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { dinov2Service } from "../services"; +import { Dinov2Status } from "../types"; + +// a backend that cannot answer has no DINOv2 check to offer: an older one +// without the endpoint, say +const NOT_CONFIGURED: Dinov2Status = { configured: false, available: false }; + +// Whether the backend can run the DINOv2 check; undefined while loading. +export function useDinov2Status(): Dinov2Status | undefined { + const [status, setStatus] = useState(); + + useEffect(() => { + let active = true; + const update = (next: Dinov2Status) => { + if (active) { + setStatus(next); + } + }; + dinov2Service + .getStatus() + .then(update) + .catch(() => update(NOT_CONFIGURED)); + return () => { + active = false; + }; + }, []); + + return status; +} diff --git a/src/components/ProjectForm/BlurAmplitudeFields.tsx b/src/components/ProjectForm/BlurAmplitudeFields.tsx index d0b0d455..8e582d00 100644 --- a/src/components/ProjectForm/BlurAmplitudeFields.tsx +++ b/src/components/ProjectForm/BlurAmplitudeFields.tsx @@ -4,6 +4,7 @@ import { TextValidator } from "react-material-ui-form-validator"; import { DEFAULT_BLUR_AMPLITUDE_THRESHOLD } from "../../constants"; import { BlurAmplitudeConfig } from "../../types/imageComparison"; import { Tooltip } from "../Tooltip"; +import { Dinov2Fields } from "./Dinov2Fields"; import { useConfigHook } from "./useConfigHook"; // Rendered by the config form of every library that supports the filter. @@ -35,39 +36,43 @@ export const BlurAmplitudeFields: React.FunctionComponent = () => { /> {config.blurAmplitudeFilter === true && ( - ) => { - const value = parseFloat(event.target.value); - updateConfig( - "blurAmplitudeThreshold", - Number.isNaN(value) ? null : value, - ); - }} - /> + + ) => { + const value = parseFloat(event.target.value); + updateConfig( + "blurAmplitudeThreshold", + Number.isNaN(value) ? null : value, + ); + }} + /> + + )} ); diff --git a/src/components/ProjectForm/Dinov2Fields.tsx b/src/components/ProjectForm/Dinov2Fields.tsx new file mode 100644 index 00000000..70b6713d --- /dev/null +++ b/src/components/ProjectForm/Dinov2Fields.tsx @@ -0,0 +1,110 @@ +import { FormControlLabel, FormHelperText, Switch } from "@mui/material"; +import React from "react"; +import { TextValidator } from "react-material-ui-form-validator"; +import { useDinov2Status } from "../../_helpers/useDinov2Status"; +import { DEFAULT_DINOV2_THRESHOLD } from "../../constants"; +import { BlurAmplitudeConfig, Dinov2Status } from "../../types"; +import { Tooltip } from "../Tooltip"; +import { useConfigHook } from "./useConfigHook"; + +// What the check does on this server, said where it matters: with no service +// the backend sends every run the filter would pass to review. +const statusNote = ( + status: Dinov2Status | undefined, + checked: boolean, +): { text: string; error?: boolean; warning?: boolean } | undefined => { + if (status === undefined) { + return undefined; + } + if (!status.configured) { + return checked + ? { + text: "This server has no DINOv2 service: every run the filter would pass goes to review. Turn the check off or set DINOV2_URL on the backend.", + error: true, + } + : { + text: "Not available: this server has no DINOv2 service (DINOV2_URL).", + }; + } + if (!status.available) { + return { + text: "The DINOv2 service is not answering right now. Until it does, runs the filter would pass go to review.", + warning: true, + }; + } + return undefined; +}; + +// Rendered under the rendering-noise filter while it is on. +export const Dinov2Fields: React.FunctionComponent = () => { + const [config, updateConfig] = useConfigHook(); + const status = useDinov2Status(); + const checked = config.dinov2Check === true; + const note = statusNote(status, checked); + + // absent: the backend default applies; null: the field was cleared, which + // the required validator then refuses + const threshold = + config.dinov2Threshold === undefined + ? DEFAULT_DINOV2_THRESHOLD + : (config.dinov2Threshold ?? ""); + + return ( + + + updateConfig("dinov2Check", value)} + color="primary" + name="dinov2Check" + /> + } + /> + + {note && ( + + {note.text} + + )} + {checked && ( + ) => { + const value = parseFloat(event.target.value); + updateConfig("dinov2Threshold", Number.isNaN(value) ? null : value); + }} + /> + )} + + ); +}; diff --git a/src/components/ProjectForm/ProjectForm.test.tsx b/src/components/ProjectForm/ProjectForm.test.tsx index 609dc36f..b3db0e6a 100644 --- a/src/components/ProjectForm/ProjectForm.test.tsx +++ b/src/components/ProjectForm/ProjectForm.test.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { screen, waitFor, within } from "@testing-library/react"; import { renderWithProviders } from "../../_test/render"; -import { ollamaService } from "../../services"; +import { dinov2Service, ollamaService } from "../../services"; import { LOOKSSAME_DEFAULT_CONFIG, ODIFF_DEFAULT_CONFIG, @@ -120,6 +120,10 @@ const ALL_ENGINES = [ describe("ProjectForm", () => { beforeEach(() => { vi.spyOn(ollamaService, "listModels").mockResolvedValue([]); + vi.spyOn(dinov2Service, "getStatus").mockResolvedValue({ + configured: true, + available: true, + }); }); describe("project settings", () => { @@ -601,6 +605,18 @@ describe("ProjectForm", () => { expect(noiseThreshold()).toHaveValue(42); }); + it("does not submit a fraction above 128", async () => { + const onSubmit = vi.fn(); + const { user } = await renderForm(PROJECT, onSubmit); + + await user.click(noiseFilter()); + await replaceNumber(user, noiseThreshold() as HTMLElement, "128.5"); + await user.click(screen.getByTestId("submitButton")); + + expect(await screen.findByText("Enter at most 128")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("does not submit a threshold above 128", async () => { const onSubmit = vi.fn(); const { user } = await renderForm(PROJECT, onSubmit); @@ -648,6 +664,135 @@ describe("ProjectForm", () => { }); }); + describe("DINOv2 check", () => { + const dinov2Check = () => screen.getByLabelText("Check with DINOv2"); + const dinov2Threshold = () => screen.queryByLabelText(/DINOv2 threshold/); + const withFilter = (config: object = {}): ProjectDto => ({ + ...PROJECT, + imageComparisonConfig: JSON.stringify({ + ...JSON.parse(PIXELMATCH_DEFAULT_CONFIG), + blurAmplitudeFilter: true, + ...config, + }), + }); + const serverHas = (configured: boolean, available = configured) => + vi + .spyOn(dinov2Service, "getStatus") + .mockResolvedValue({ configured, available }); + + it("is offered only while the noise filter is on", async () => { + const { user } = await renderForm(); + + expect(screen.queryByLabelText("Check with DINOv2")).toBeNull(); + await user.click(screen.getByLabelText("Ignore rendering noise")); + + expect(dinov2Check()).not.toBeChecked(); + expect(dinov2Threshold()).toBeNull(); + }); + + it("writes the switch and the threshold into the config json", async () => { + const { user } = await renderForm(withFilter()); + + await waitFor(() => expect(dinov2Check()).toBeEnabled()); + await user.click(dinov2Check()); + // the backend's default, until changed + expect(dinov2Threshold()).toHaveValue(0.15); + await replaceNumber(user, dinov2Threshold() as HTMLElement, "0.2"); + + expect(editConfig()).toEqual({ + ...JSON.parse(PIXELMATCH_DEFAULT_CONFIG), + blurAmplitudeFilter: true, + dinov2Check: true, + dinov2Threshold: 0.2, + }); + }); + + it("shows the check of a project saved with it on", async () => { + await renderForm(withFilter({ dinov2Check: true, dinov2Threshold: 0.3 })); + + expect(dinov2Check()).toBeChecked(); + expect(dinov2Threshold()).toHaveValue(0.3); + }); + + it("cannot be turned on where the server has no DINOv2 service", async () => { + serverHas(false); + await renderForm(withFilter()); + + expect( + await screen.findByText( + "Not available: this server has no DINOv2 service (DINOV2_URL).", + ), + ).toBeVisible(); + expect(dinov2Check()).toBeDisabled(); + }); + + // an older backend has no status endpoint, and no check either + it("cannot be turned on when the server does not say", async () => { + vi.spyOn(dinov2Service, "getStatus").mockRejectedValue("Not Found"); + await renderForm(withFilter()); + + await waitFor(() => expect(dinov2Check()).toBeDisabled()); + }); + + // with no service the backend sends every run the filter would pass to + // review: say so, and let it be turned off + it("lets a project saved with it on turn it off where there is no service", async () => { + serverHas(false); + const { user } = await renderForm(withFilter({ dinov2Check: true })); + + expect( + await screen.findByText( + /every run the filter would pass goes to review/, + ), + ).toBeVisible(); + await user.click(dinov2Check()); + + expect(editConfig().dinov2Check).toBe(false); + expect(dinov2Check()).toBeDisabled(); + }); + + it("warns when the service is set up but not answering", async () => { + serverHas(true, false); + await renderForm(withFilter()); + + expect( + await screen.findByText( + /The DINOv2 service is not answering right now/, + ), + ).toBeVisible(); + expect(dinov2Check()).toBeEnabled(); + }); + + it("does not submit a threshold above 2", async () => { + const onSubmit = vi.fn(); + const { user } = await renderForm( + withFilter({ dinov2Check: true }), + onSubmit, + ); + + await replaceNumber(user, dinov2Threshold() as HTMLElement, "2.5"); + await user.click(screen.getByTestId("submitButton")); + + expect(await screen.findByText("Enter at most 2")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("keeps the check when the library changes", async () => { + const { user } = await renderForm( + withFilter({ dinov2Check: true, dinov2Threshold: 0.25 }), + ); + + await switchEngine(user, ImageComparison.lookSame); + + expect(editConfig()).toEqual({ + ...JSON.parse(LOOKSSAME_DEFAULT_CONFIG), + blurAmplitudeFilter: true, + dinov2Check: true, + dinov2Threshold: 0.25, + }); + }); + }); + describe("vlm config", () => { const vlm: ProjectDto = { ...PROJECT, diff --git a/src/components/ProjectForm/utils.ts b/src/components/ProjectForm/utils.ts index 68faf908..420443f7 100644 --- a/src/components/ProjectForm/utils.ts +++ b/src/components/ProjectForm/utils.ts @@ -33,11 +33,17 @@ export const withBlurAmplitudeOf = ( config: string, previousConfig: string, ): string => { - const { blurAmplitudeFilter, blurAmplitudeThreshold }: BlurAmplitudeConfig = - JSON.parse(previousConfig); + const { + blurAmplitudeFilter, + blurAmplitudeThreshold, + dinov2Check, + dinov2Threshold, + }: BlurAmplitudeConfig = JSON.parse(previousConfig); return JSON.stringify({ ...JSON.parse(config), blurAmplitudeFilter, blurAmplitudeThreshold, + dinov2Check, + dinov2Threshold, }); }; diff --git a/src/constants/project.ts b/src/constants/project.ts index 38afb2fa..e8d9e72b 100644 --- a/src/constants/project.ts +++ b/src/constants/project.ts @@ -5,6 +5,9 @@ export const PIXELMATCH_DEFAULT_CONFIG = '{"threshold":0.1,"ignoreAntialiasing":true,"allowDiffDimensions":false}'; // the backend's default when a config turns the filter on without a threshold export const DEFAULT_BLUR_AMPLITUDE_THRESHOLD = 80; +// the backend's default for the DINOv2 check: above the strongest re-render +// noise (0.133) it was calibrated on +export const DEFAULT_DINOV2_THRESHOLD = 0.15; export const LOOKSSAME_DEFAULT_CONFIG = '{"strict":false,"tolerance":2.3,"ignoreAntialiasing":true,"antialiasingTolerance":0,"ignoreCaret":true,"allowDiffDimensions":false}'; export const ODIFF_DEFAULT_CONFIG = diff --git a/src/services/dinov2.service.test.ts b/src/services/dinov2.service.test.ts new file mode 100644 index 00000000..3ebfae3c --- /dev/null +++ b/src/services/dinov2.service.test.ts @@ -0,0 +1,37 @@ +import { dinov2Service } from "./dinov2.service"; +import { API, stubFetch } from "../_test/fetch.helper"; + +vi.mock("../_config/env.config", () => ({ API_URL: "http://api.test" })); + +describe("dinov2Service", () => { + beforeEach(() => { + localStorage.setItem("user", JSON.stringify({ token: "token-1" })); + }); + + afterEach(() => { + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + describe("getStatus", () => { + it("fetches whether the service is set up and answering", async () => { + const fetchMock = stubFetch({ configured: true, available: false }); + + const result = await dinov2Service.getStatus(); + + expect(result).toEqual({ configured: true, available: false }); + expect(fetchMock).toHaveBeenCalledWith(`${API}/dinov2/status`, { + method: "GET", + headers: { Authorization: "Bearer token-1" }, + }); + }); + + it("rejects with the API message when the request fails", async () => { + stubFetch({ message: "Forbidden resource" }, { status: 403 }); + + await expect(dinov2Service.getStatus()).rejects.toBe( + "Forbidden resource", + ); + }); + }); +}); diff --git a/src/services/dinov2.service.ts b/src/services/dinov2.service.ts new file mode 100644 index 00000000..1ec3be09 --- /dev/null +++ b/src/services/dinov2.service.ts @@ -0,0 +1,17 @@ +import { handleResponse, authHeader } from "../_helpers/service.helpers"; +import { API_URL } from "../_config/env.config"; +import { Dinov2Status } from "../types"; + +async function getStatus(): Promise { + const requestOptions = { + method: "GET", + headers: authHeader(), + }; + + const response = await fetch(`${API_URL}/dinov2/status`, requestOptions); + return handleResponse(response); +} + +export const dinov2Service = { + getStatus, +}; diff --git a/src/services/index.ts b/src/services/index.ts index 9be90037..d797f21f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -5,3 +5,4 @@ export * from "./static.service"; export * from "./testVariation.service"; export * from "./testRun.service"; export * from "./ollama.service"; +export * from "./dinov2.service"; diff --git a/src/types/dinov2.ts b/src/types/dinov2.ts new file mode 100644 index 00000000..f78de2dd --- /dev/null +++ b/src/types/dinov2.ts @@ -0,0 +1,8 @@ +// GET /dinov2/status: whether this server can run the rendering-noise +// filter's DINOv2 check +export interface Dinov2Status { + // DINOV2_URL is set on the backend + configured: boolean; + // the service answered its health check just now + available: boolean; +} diff --git a/src/types/imageComparison.ts b/src/types/imageComparison.ts index 92adffe7..ce00b72b 100644 --- a/src/types/imageComparison.ts +++ b/src/types/imageComparison.ts @@ -18,6 +18,10 @@ export interface BlurAmplitudeConfig { blurAmplitudeFilter?: boolean; // 0-128; absent means the backend default blurAmplitudeThreshold?: number | null; + // a second look by the DINOv2 service before the filter passes a run + dinov2Check?: boolean; + // (0, 2]; absent means the backend default + dinov2Threshold?: number | null; } export interface PixelmatchConfig extends BlurAmplitudeConfig { diff --git a/src/types/index.ts b/src/types/index.ts index d862c49d..51abad77 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,3 +7,4 @@ export * from "./testStatus"; export * from "./paginatedData"; export * from "./imageComparison"; export * from "./ollama"; +export * from "./dinov2";