diff --git a/README.md b/README.md index aaf82a6..61abc37 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ The testing related `.spec.tsx` files are used with Playwright for browser tests ## Running Tests ### Unit Tests (Jest) + Run Jest unit tests: + ```bash npm run test ``` @@ -22,7 +24,9 @@ npm run test ### Integration Tests (Playwright) #### Local Testing + Run Playwright tests locally (requires local dev server): + ```bash npm run test:pw:local ``` diff --git a/src/components/ProjectForm/GeminiConfigForm.tsx b/src/components/ProjectForm/GeminiConfigForm.tsx new file mode 100644 index 0000000..79da10a --- /dev/null +++ b/src/components/ProjectForm/GeminiConfigForm.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { Link } from "@mui/material"; +import { TextValidator } from "react-material-ui-form-validator"; +import { GeminiVlmConfig } from "../../types/imageComparison"; +import { Tooltip } from "../Tooltip"; +import { useConfigHook } from "./useConfigHook"; +import { + useProjectState, + useProjectDispatch, + setProjectEditState, +} from "../../contexts"; +import { + VlmPromptField, + VlmTemperatureField, + VlmUseThinkingField, +} from "./VlmSharedFields"; + +export const GeminiConfigForm: React.FunctionComponent = () => { + const [config, updateConfig] = useConfigHook(); + const { projectEditState: project } = useProjectState(); + const projectDispatch = useProjectDispatch(); + + return ( + + +
+ ) => { + updateConfig("model", event.target.value); + }} + helperText={ + + Gemini model name.{" "} + e.stopPropagation()} + > + View all available models + + + } + /> +
+
+ +
+ ) => { + const updatedConfig: GeminiVlmConfig = { + ...config, + apiKey: event.target.value, + }; + setProjectEditState(projectDispatch, { + ...project, + imageComparisonConfig: JSON.stringify(updatedConfig), + }); + }} + helperText="Enter your Google Gemini API key" + /> +
+
+ updateConfig("prompt", value)} + /> + updateConfig("temperature", value)} + /> + updateConfig("useThinking", value)} + /> +
+ ); +}; + diff --git a/src/components/ProjectForm/OllamaConfigForm.tsx b/src/components/ProjectForm/OllamaConfigForm.tsx new file mode 100644 index 0000000..fd82a31 --- /dev/null +++ b/src/components/ProjectForm/OllamaConfigForm.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + LinearProgress, + FormControl, + InputLabel, + Select, + MenuItem, + FormHelperText, + SelectChangeEvent, +} from "@mui/material"; +import { TextValidator } from "react-material-ui-form-validator"; +import { useSnackbar } from "notistack"; +import { OllamaVlmConfig } from "../../types/imageComparison"; +import { Tooltip } from "../Tooltip"; +import { useConfigHook } from "./useConfigHook"; +import { ollamaService } from "../../services"; +import { OllamaModel } from "../../types"; +import { + VlmPromptField, + VlmTemperatureField, + VlmUseThinkingField, +} from "./VlmSharedFields"; + +export const OllamaConfigForm: React.FunctionComponent = () => { + const { enqueueSnackbar } = useSnackbar(); + const [config, updateConfig] = useConfigHook(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let isMounted = true; + setLoading(true); + ollamaService + .listModels() + .then((fetchedModels) => { + if (isMounted) { + setModels(fetchedModels); + } + }) + .catch((err) => { + if (isMounted) { + enqueueSnackbar(err, { + variant: "error", + }); + } + }) + .finally(() => { + if (isMounted) { + setLoading(false); + } + }); + return () => { + isMounted = false; + }; + }, [enqueueSnackbar]); + + const hasError = useMemo(() => { + if (!config.model || loading || models.length === 0) { + return false; + } + return !models.some((m) => m.name === config.model); + }, [config.model, models, loading]); + + const handleModelChange = (event: SelectChangeEvent) => { + updateConfig("model", event.target.value); + }; + + const renderModelField = () => { + if (loading) { + return ( + + ); + } + + if (models.length === 0) { + return ( + ) => { + updateConfig("model", event.target.value); + }} + /> + ); + } + + return ( + + Model + + {hasError && ( + + Selected model is not in the available list. + + )} + + ); + }; + + return ( + + +
+ {renderModelField()} + +
+
+ {loading && } + updateConfig("prompt", value)} + /> + updateConfig("temperature", value)} + /> + updateConfig("useThinking", value)} + /> +
+ ); +}; + diff --git a/src/components/ProjectForm/ProjectForm.tsx b/src/components/ProjectForm/ProjectForm.tsx index 78cde6d..4f43fb7 100644 --- a/src/components/ProjectForm/ProjectForm.tsx +++ b/src/components/ProjectForm/ProjectForm.tsx @@ -181,9 +181,7 @@ export const ProjectForm: React.FunctionComponent = () => { {ImageComparison.odiff} - - {ImageComparison.vlm} - + {ImageComparison.vlm} {config} diff --git a/src/components/ProjectForm/VlmConfigForm.tsx b/src/components/ProjectForm/VlmConfigForm.tsx index f1e0e1a..4a194fa 100644 --- a/src/components/ProjectForm/VlmConfigForm.tsx +++ b/src/components/ProjectForm/VlmConfigForm.tsx @@ -1,201 +1,87 @@ -import React, { useState, useEffect, useMemo } from "react"; -import { LinearProgress, FormControl, InputLabel, Select, MenuItem, FormHelperText, SelectChangeEvent, FormControlLabel, Switch } from "@mui/material"; -import { TextValidator } from "react-material-ui-form-validator"; -import { useSnackbar } from "notistack"; +import React, { useMemo } from "react"; +import { + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, +} from "@mui/material"; import { VlmConfig } from "../../types/imageComparison"; -import { Tooltip } from "../Tooltip"; import { useConfigHook } from "./useConfigHook"; -import { ollamaService } from "../../services"; -import { OllamaModel } from "../../types"; +import { + useProjectState, + useProjectDispatch, + setProjectEditState, +} from "../../contexts"; +import { GeminiConfigForm } from "./GeminiConfigForm"; +import { OllamaConfigForm } from "./OllamaConfigForm"; export const VlmConfigForm: React.FunctionComponent = () => { - const { enqueueSnackbar } = useSnackbar(); - const [config, updateConfig] = useConfigHook(); - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(false); + const [config] = useConfigHook(); + const { projectEditState: project } = useProjectState(); + const projectDispatch = useProjectDispatch(); - useEffect(() => { - let isMounted = true; - setLoading(true); - ollamaService - .listModels() - .then((fetchedModels) => { - if (isMounted) { - setModels(fetchedModels); - } - }) - .catch((err) => { - if (isMounted) { - enqueueSnackbar(err, { - variant: "error", - }); - } - }) - .finally(() => { - if (isMounted) { - setLoading(false); - } - }); - return () => { - isMounted = false; - }; - }, [enqueueSnackbar]); - - const hasError = useMemo(() => { - if (!config.model || loading || models.length === 0) { - return false; + // Determine current provider (default to ollama if not set) + const currentProvider = useMemo(() => { + if ("provider" in config && config.provider === "gemini") { + return "gemini"; } - return !models.some((m) => m.name === config.model); - }, [config.model, models, loading]); + return "ollama"; + }, [config]); - const handleModelChange = (event: SelectChangeEvent) => { - updateConfig("model", event.target.value); - }; + const handleProviderChange = (event: SelectChangeEvent) => { + const newProvider = event.target.value as "ollama" | "gemini"; + const currentPrompt = config.prompt; + const currentTemperature = config.temperature; + const currentUseThinking = config.useThinking; - const renderModelField = () => { - if (loading) { - return ( - - ); + // We need to update the entire config when switching providers + let newConfig: VlmConfig; + if (newProvider === "gemini") { + newConfig = { + provider: "gemini", + model: "gemini-1.5-pro", + apiKey: "", + prompt: currentPrompt, + temperature: currentTemperature, + useThinking: currentUseThinking, + }; + } else { + newConfig = { + provider: "ollama", + model: "", + prompt: currentPrompt, + temperature: currentTemperature, + useThinking: currentUseThinking, + }; } - if (models.length === 0) { - return ( - ) => { - updateConfig("model", event.target.value); - }} - /> - ); - } + setProjectEditState(projectDispatch, { + ...project, + imageComparisonConfig: JSON.stringify(newConfig), + }); + }; - return ( - - Model + return ( + + + VLM Provider - {hasError && ( - Selected model is not in the available list. - )} - ); - }; - - return ( - - -
- {renderModelField()} - -
-
- {loading && } - -
- ) => { - updateConfig("prompt", event.target.value); - }} - /> -
-
- -
- ) => { - const { value } = event.target; - updateConfig("temperature", parseFloat(value)); - }} - /> -
-
- - - updateConfig("useThinking", checked) - } - color="primary" - name="useThinking" - /> - } - /> - + {currentProvider === "gemini" ? ( + + ) : ( + + )}
); }; - diff --git a/src/components/ProjectForm/VlmSharedFields.tsx b/src/components/ProjectForm/VlmSharedFields.tsx new file mode 100644 index 0000000..0eb08c5 --- /dev/null +++ b/src/components/ProjectForm/VlmSharedFields.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { FormControlLabel, Switch } from "@mui/material"; +import { TextValidator } from "react-material-ui-form-validator"; +import { Tooltip } from "../Tooltip"; + +interface VlmSharedFieldsProps { + prompt: string; + temperature: number; + useThinking: boolean; + onPromptChange: (value: string) => void; + onTemperatureChange: (value: number) => void; + onUseThinkingChange: (value: boolean) => void; +} + +export const VlmPromptField: React.FunctionComponent<{ + value: string; + onChange: (value: string) => void; +}> = ({ value, onChange }) => { + return ( + +
+ ) => { + onChange(event.target.value); + }} + /> +
+
+ ); +}; + +export const VlmTemperatureField: React.FunctionComponent<{ + value: number; + onChange: (value: number) => void; +}> = ({ value, onChange }) => { + return ( + +
+ ) => { + const { value: inputValue } = event.target; + onChange(Number.parseFloat(inputValue)); + }} + /> +
+
+ ); +}; + +export const VlmUseThinkingField: React.FunctionComponent<{ + value: boolean; + onChange: (value: boolean) => void; +}> = ({ value, onChange }) => { + return ( + + onChange(checked)} + color="primary" + name="useThinking" + /> + } + /> + + ); +}; diff --git a/src/components/TestDetailsDialog/TestRunDetails.tsx b/src/components/TestDetailsDialog/TestRunDetails.tsx index bd67aa3..e38b6bd 100644 --- a/src/components/TestDetailsDialog/TestRunDetails.tsx +++ b/src/components/TestDetailsDialog/TestRunDetails.tsx @@ -53,7 +53,9 @@ export const TestRunDetails: React.FunctionComponent = ({ )} {testRun.viewport && ( - Viewport: {testRun.viewport} + + Viewport: {testRun.viewport} + )} {testRun.customTags && ( @@ -66,7 +68,9 @@ export const TestRunDetails: React.FunctionComponent = ({ - Diff: {Math.round(testRun.diffPercent * 100) / 100}% + + Diff: {Math.round(testRun.diffPercent * 100) / 100}% + diff --git a/src/constants/project.ts b/src/constants/project.ts index a521ae4..755e9d3 100644 --- a/src/constants/project.ts +++ b/src/constants/project.ts @@ -8,6 +8,7 @@ export const LOOKSSAME_DEFAULT_CONFIG = export const ODIFF_DEFAULT_CONFIG = '{"threshold":0,"antialiasing":true,"failOnLayoutDiff":true,"outputDiffMask":true}'; export const VLM_DEFAULT_CONFIG = JSON.stringify({ + provider: "ollama", model: "", prompt: `You are provided with three images: 1. First image: baseline screenshot diff --git a/src/services/ollama.service.ts b/src/services/ollama.service.ts index b8ae221..2e5a577 100644 --- a/src/services/ollama.service.ts +++ b/src/services/ollama.service.ts @@ -20,5 +20,3 @@ async function listModels(): Promise { export const ollamaService = { listModels, }; - - diff --git a/src/types/imageComparison.ts b/src/types/imageComparison.ts index f39ee57..b05e37e 100644 --- a/src/types/imageComparison.ts +++ b/src/types/imageComparison.ts @@ -52,9 +52,64 @@ export interface OdiffConfig { antialiasing: boolean; } -export interface VlmConfig { - model: string; +/** + * Base configuration shared by all VLM providers + */ +export interface BaseVlmConfig { + /** + * Custom prompt for image comparison. + */ prompt: string; + + /** + * Temperature parameter controlling response randomness (0.0-1.0). + * Lower values = more consistent results. + * @default 0.1 + */ temperature: number; + + /** + * Whether to prefer thinking field over content field for response. + * Some models return result in thinking field instead of response. + * @default false + */ useThinking?: boolean; } + +/** + * Configuration for Ollama provider + */ +export interface OllamaVlmConfig extends BaseVlmConfig { + provider?: 'ollama'; + + /** + * Ollama vision model name. + * Examples: "gemma3:12b", "llava:13b" + * @default "gemma3:12b" + */ + model: string; +} + +/** + * Configuration for Gemini provider + */ +export interface GeminiVlmConfig extends BaseVlmConfig { + provider: 'gemini'; + + /** + * Gemini model name. + * Examples: "gemini-1.5-pro", "gemini-1.5-flash" + */ + model: string; + + /** + * Google Gemini API key (required). + * Stored in project settings for per-project configuration. + */ + apiKey: string; +} + +/** + * Union type for all VLM provider configurations + */ +export type VlmConfig = OllamaVlmConfig | GeminiVlmConfig; diff --git a/src/types/ollama.ts b/src/types/ollama.ts index 028ac97..2c4f7c5 100644 --- a/src/types/ollama.ts +++ b/src/types/ollama.ts @@ -4,4 +4,3 @@ export interface OllamaModel { digest?: string; modified_at?: string; } -