From 2023311c5fbb1e702c7d8ded525164f21effdace Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 4 Apr 2026 06:47:08 -0500 Subject: [PATCH] add DictAsYamlField for genai provider and runtime options --- frigate/config/camera/genai.py | 4 +- web/package-lock.json | 17 ++- web/package.json | 2 + .../config-form/section-configs/genai.ts | 9 +- .../theme/fields/DictAsYamlField.tsx | 112 ++++++++++++++++++ .../config-form/theme/frigateTheme.ts | 2 + 6 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 web/src/components/config-form/theme/fields/DictAsYamlField.tsx diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 2adf223c19..721eeb60d8 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -59,11 +59,11 @@ class GenAIConfig(FrigateBaseModel): default={}, title="Provider options", description="Additional provider-specific options to pass to the GenAI client.", - json_schema_extra={"additionalProperties": {"type": "string"}}, + json_schema_extra={"additionalProperties": {}}, ) runtime_options: dict[str, Any] = Field( default={}, title="Runtime options", description="Runtime options passed to the provider for each inference call.", - json_schema_extra={"additionalProperties": {"type": "string"}}, + json_schema_extra={"additionalProperties": {}}, ) diff --git a/web/package-lock.json b/web/package-lock.json index f910cb4ad6..494498f30e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -52,6 +52,7 @@ "i18next-http-backend": "^3.0.1", "idb-keyval": "^6.2.1", "immer": "^10.1.1", + "js-yaml": "^4.1.1", "konva": "^10.2.3", "lodash": "^4.17.23", "lucide-react": "^0.577.0", @@ -90,6 +91,7 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.9", "@testing-library/jest-dom": "^6.6.2", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.12", "@types/node": "^20.14.10", "@types/react": "^19.2.14", @@ -5494,6 +5496,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6132,7 +6141,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -9178,10 +9186,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/web/package.json b/web/package.json index ed522b827e..960b556ffa 100644 --- a/web/package.json +++ b/web/package.json @@ -61,6 +61,7 @@ "i18next-http-backend": "^3.0.1", "idb-keyval": "^6.2.1", "immer": "^10.1.1", + "js-yaml": "^4.1.1", "konva": "^10.2.3", "lodash": "^4.17.23", "lucide-react": "^0.577.0", @@ -99,6 +100,7 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.9", "@testing-library/jest-dom": "^6.6.2", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.12", "@types/node": "^20.14.10", "@types/react": "^19.2.14", diff --git a/web/src/components/config-form/section-configs/genai.ts b/web/src/components/config-form/section-configs/genai.ts index e5a2984602..a5f1cd8a32 100644 --- a/web/src/components/config-form/section-configs/genai.ts +++ b/web/src/components/config-form/section-configs/genai.ts @@ -5,6 +5,7 @@ const genai: SectionConfigOverrides = { sectionDocs: "/configuration/genai/config", advancedFields: ["*.base_url", "*.provider_options", "*.runtime_options"], hiddenFields: ["genai.enabled_in_config"], + restartRequired: [], uiSchema: { "ui:options": { disableNestedCard: true }, "*": { @@ -36,14 +37,10 @@ const genai: SectionConfigOverrides = { "ui:options": { size: "xs" }, }, "*.provider_options": { - additionalProperties: { - "ui:options": { size: "lg" }, - }, + "ui:field": "DictAsYamlField", }, "*.runtime_options": { - additionalProperties: { - "ui:options": { size: "lg" }, - }, + "ui:field": "DictAsYamlField", }, }, }, diff --git a/web/src/components/config-form/theme/fields/DictAsYamlField.tsx b/web/src/components/config-form/theme/fields/DictAsYamlField.tsx new file mode 100644 index 0000000000..0b21f9b1aa --- /dev/null +++ b/web/src/components/config-form/theme/fields/DictAsYamlField.tsx @@ -0,0 +1,112 @@ +import type { FieldPathList, FieldProps } from "@rjsf/utils"; +import yaml from "js-yaml"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +function formatYaml(value: unknown): string { + if ( + value == null || + (typeof value === "object" && + !Array.isArray(value) && + Object.keys(value as Record).length === 0) + ) { + return ""; + } + try { + return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd(); + } catch { + return ""; + } +} + +function parseYaml(text: string): { + value: Record | undefined; + error: string | undefined; +} { + const trimmed = text.trim(); + if (trimmed === "") { + return { value: {}, error: undefined }; + } + try { + const parsed = yaml.load(trimmed); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return { value: undefined, error: "Must be a YAML mapping" }; + } + return { value: parsed as Record, error: undefined }; + } catch (e) { + const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML"; + return { value: undefined, error: msg }; + } +} + +export function DictAsYamlField(props: FieldProps) { + const { formData, onChange, readonly, disabled, idSchema, schema } = props; + + const emptyPath = useMemo(() => [] as FieldPathList, []); + const fieldPath = + (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? + emptyPath; + + const [text, setText] = useState(() => formatYaml(formData)); + const [error, setError] = useState(); + + useEffect(() => { + setText(formatYaml(formData)); + setError(undefined); + }, [formData]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.value; + setText(raw); + const { value, error: parseError } = parseYaml(raw); + setError(parseError); + if (value !== undefined) { + onChange(value, fieldPath); + } + }, + [onChange, fieldPath], + ); + + const handleBlur = useCallback( + (_e: React.FocusEvent) => { + // Reformat on blur if valid + const { value } = parseYaml(text); + if (value !== undefined) { + setText(formatYaml(value)); + } + }, + [text], + ); + + const id = idSchema?.$id ?? props.name; + + return ( +
+ {schema.title && ( + + )} +