mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-29 16:41:16 +03:00
add model fetcher endpoint for genai config ui
This commit is contained in:
parent
237eb64011
commit
07ca84ad0a
74
docs/static/frigate-api.yaml
vendored
74
docs/static/frigate-api.yaml
vendored
@ -2058,6 +2058,47 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/HTTPValidationError"
|
$ref: "#/components/schemas/HTTPValidationError"
|
||||||
|
/genai/models:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- App
|
||||||
|
summary: List available GenAI models
|
||||||
|
description: Returns available models for each configured GenAI provider.
|
||||||
|
operationId: genai_models_genai_models_get
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
/genai/probe:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- App
|
||||||
|
summary: Probe a GenAI provider without saving config
|
||||||
|
description: >-
|
||||||
|
Builds a transient client from the request body and returns its
|
||||||
|
available models. Used to validate provider credentials in the UI
|
||||||
|
before saving the configuration. Requires admin role.
|
||||||
|
operationId: genai_probe_genai_probe_post
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GenAIProbeBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
"422":
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/HTTPValidationError"
|
||||||
/vainfo:
|
/vainfo:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -7031,6 +7072,39 @@ components:
|
|||||||
"john_doe": ["face1.webp", "face2.jpg"],
|
"john_doe": ["face1.webp", "face2.jpg"],
|
||||||
"jane_smith": ["face3.png"]
|
"jane_smith": ["face3.png"]
|
||||||
}
|
}
|
||||||
|
GenAIProbeBody:
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- openai
|
||||||
|
- azure_openai
|
||||||
|
- gemini
|
||||||
|
- ollama
|
||||||
|
- llamacpp
|
||||||
|
title: Provider
|
||||||
|
description: GenAI provider to probe
|
||||||
|
api_key:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: "null"
|
||||||
|
title: API Key
|
||||||
|
description: API key for the provider (when applicable)
|
||||||
|
base_url:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: "null"
|
||||||
|
title: Base URL
|
||||||
|
description: Base URL for self-hosted or compatible providers
|
||||||
|
provider_options:
|
||||||
|
type: object
|
||||||
|
title: Provider Options
|
||||||
|
description: Additional provider-specific options
|
||||||
|
default: {}
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
title: GenAIProbeBody
|
||||||
GenerateObjectExamplesBody:
|
GenerateObjectExamplesBody:
|
||||||
properties:
|
properties:
|
||||||
model_name:
|
model_name:
|
||||||
|
|||||||
@ -34,15 +34,17 @@ from frigate.api.auth import (
|
|||||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
from frigate.api.defs.request.app_body import (
|
from frigate.api.defs.request.app_body import (
|
||||||
AppConfigSetBody,
|
AppConfigSetBody,
|
||||||
|
GenAIProbeBody,
|
||||||
MediaSyncBody,
|
MediaSyncBody,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||||
|
from frigate.genai import PROVIDERS, load_providers
|
||||||
from frigate.jobs.media_sync import (
|
from frigate.jobs.media_sync import (
|
||||||
get_current_media_sync_job,
|
get_current_media_sync_job,
|
||||||
get_media_sync_job_by_id,
|
get_media_sync_job_by_id,
|
||||||
@ -75,6 +77,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(tags=[Tags.app])
|
router = APIRouter(tags=[Tags.app])
|
||||||
|
|
||||||
|
# Short timeout for the /genai/probe path. The probe is interactive — fail
|
||||||
|
# fast on hung providers rather than holding an API worker thread.
|
||||||
|
_PROBE_TIMEOUT_SECONDS = 10
|
||||||
|
# Outer cap that returns control to the caller even if the underlying sync
|
||||||
|
# HTTP call ignores its timeout. The sync work continues in the background
|
||||||
|
# thread; only the response is bounded.
|
||||||
|
_PROBE_OUTER_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||||
@ -170,6 +180,95 @@ def genai_models(request: Request):
|
|||||||
return JSONResponse(content=request.app.genai_manager.list_models())
|
return JSONResponse(content=request.app.genai_manager.list_models())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/genai/probe",
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
summary="Probe a GenAI provider without saving config",
|
||||||
|
description=(
|
||||||
|
"Builds a transient client from the request body and returns its "
|
||||||
|
"available models. Used to validate provider credentials in the UI "
|
||||||
|
"before saving the configuration."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def genai_probe(body: GenAIProbeBody):
|
||||||
|
load_providers()
|
||||||
|
|
||||||
|
provider_cls = PROVIDERS.get(body.provider)
|
||||||
|
if not provider_cls:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"success": False, "message": "Unknown provider"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||||
|
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||||
|
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||||
|
# in milliseconds and would clash with the plugin's own default.
|
||||||
|
probe_provider_options: dict[str, Any] = dict(body.provider_options or {})
|
||||||
|
if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai):
|
||||||
|
probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
try:
|
||||||
|
transient_cfg = GenAIConfig(
|
||||||
|
provider=body.provider,
|
||||||
|
api_key=body.api_key,
|
||||||
|
base_url=body.base_url,
|
||||||
|
provider_options=probe_provider_options,
|
||||||
|
# model is required by the schema but irrelevant for listing.
|
||||||
|
model="probe",
|
||||||
|
roles=[],
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
logger.exception("GenAI probe: invalid configuration")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"success": False, "message": "Invalid provider configuration"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = provider_cls(
|
||||||
|
transient_cfg,
|
||||||
|
timeout=_PROBE_TIMEOUT_SECONDS,
|
||||||
|
validate_model=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("GenAI probe: failed to construct client")
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to connect to provider",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
models = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.list_models),
|
||||||
|
timeout=_PROBE_OUTER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Probe timed out"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("GenAI probe: list_models failed")
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Provider returned no models"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": (
|
||||||
|
"No models returned. Check the API key, base URL, and "
|
||||||
|
"that the provider is reachable."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(content={"success": True, "models": models})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def config(request: Request):
|
def config(request: Request):
|
||||||
config_obj: FrigateConfig = request.app.frigate_config
|
config_obj: FrigateConfig = request.app.frigate_config
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from frigate.config import GenAIProviderEnum
|
||||||
|
|
||||||
|
|
||||||
class AppConfigSetBody(BaseModel):
|
class AppConfigSetBody(BaseModel):
|
||||||
requires_restart: int = 1
|
requires_restart: int = 1
|
||||||
@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
|
|||||||
skip_save: bool = False
|
skip_save: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GenAIProbeBody(BaseModel):
|
||||||
|
provider: GenAIProviderEnum
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
provider_options: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class AppPutPasswordBody(BaseModel):
|
class AppPutPasswordBody(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
old_password: Optional[str] = None
|
old_password: Optional[str] = None
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel):
|
|||||||
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
|
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
|
||||||
)
|
)
|
||||||
model: str = Field(
|
model: str = Field(
|
||||||
default="gpt-4o",
|
default="",
|
||||||
title="Model",
|
title="Model",
|
||||||
description="The model to use from the provider for generating descriptions or summaries.",
|
description="The model to use from the provider for generating descriptions or summaries.",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -50,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
|||||||
class GenAIClient:
|
class GenAIClient:
|
||||||
"""Generative AI client for Frigate."""
|
"""Generative AI client for Frigate."""
|
||||||
|
|
||||||
def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
genai_config: GenAIConfig,
|
||||||
|
timeout: int = 120,
|
||||||
|
validate_model: bool = True,
|
||||||
|
) -> None:
|
||||||
self.genai_config: GenAIConfig = genai_config
|
self.genai_config: GenAIConfig = genai_config
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.validate_model = validate_model
|
||||||
self.provider = self._init_provider()
|
self.provider = self._init_provider()
|
||||||
|
|
||||||
def generate_review_description(
|
def generate_review_description(
|
||||||
|
|||||||
@ -150,6 +150,10 @@ class LlamaCppClient(GenAIClient):
|
|||||||
else:
|
else:
|
||||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||||
|
|
||||||
|
if not self.validate_model:
|
||||||
|
# Probe path
|
||||||
|
return base_url
|
||||||
|
|
||||||
configured_model = self.genai_config.model
|
configured_model = self.genai_config.model
|
||||||
info = self._get_model_info(base_url, configured_model)
|
info = self._get_model_info(base_url, configured_model)
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,9 @@ class OllamaClient(GenAIClient):
|
|||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
headers=self._auth_headers(),
|
headers=self._auth_headers(),
|
||||||
)
|
)
|
||||||
|
if not self.validate_model:
|
||||||
|
# Probe path
|
||||||
|
return client
|
||||||
# ensure the model is available locally
|
# ensure the model is available locally
|
||||||
response = client.show(self.genai_config.model)
|
response = client.show(self.genai_config.model)
|
||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import frigate.genai
|
||||||
|
from frigate.config import GenAIProviderEnum
|
||||||
|
from frigate.genai import GenAIClient
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.stats.emitter import StatsEmitter
|
from frigate.stats.emitter import StatsEmitter
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
@ -71,3 +74,94 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
################################### POST /genai/probe Endpoint ##################################################
|
||||||
|
####################################################################################################################
|
||||||
|
def test_genai_probe_requires_admin(self):
|
||||||
|
app = super().create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/genai/probe",
|
||||||
|
json={"provider": "openai"},
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_genai_probe_returns_models_from_transient_client(self):
|
||||||
|
class FakeClient(GenAIClient):
|
||||||
|
def list_models(self):
|
||||||
|
return ["fake-model-a", "fake-model-b"]
|
||||||
|
|
||||||
|
app = super().create_app()
|
||||||
|
|
||||||
|
with (
|
||||||
|
AuthTestClient(app) as client,
|
||||||
|
patch.dict(
|
||||||
|
frigate.genai.PROVIDERS,
|
||||||
|
{GenAIProviderEnum.openai: FakeClient},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/genai/probe",
|
||||||
|
json={
|
||||||
|
"provider": "openai",
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"base_url": "https://example.invalid",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"success": True,
|
||||||
|
"models": ["fake-model-a", "fake-model-b"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||||
|
# The plugin's list_models() returns [] on connection failure rather
|
||||||
|
# than raising. The endpoint should surface that as success=false so
|
||||||
|
# the UI can show a meaningful error.
|
||||||
|
class EmptyClient(GenAIClient):
|
||||||
|
def list_models(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
app = super().create_app()
|
||||||
|
|
||||||
|
with (
|
||||||
|
AuthTestClient(app) as client,
|
||||||
|
patch.dict(
|
||||||
|
frigate.genai.PROVIDERS,
|
||||||
|
{GenAIProviderEnum.openai: EmptyClient},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/genai/probe",
|
||||||
|
json={"provider": "openai"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
def test_genai_probe_handles_provider_failure(self):
|
||||||
|
class FailingClient(GenAIClient):
|
||||||
|
def list_models(self):
|
||||||
|
raise RuntimeError("provider unreachable")
|
||||||
|
|
||||||
|
app = super().create_app()
|
||||||
|
|
||||||
|
with (
|
||||||
|
AuthTestClient(app) as client,
|
||||||
|
patch.dict(
|
||||||
|
frigate.genai.PROVIDERS,
|
||||||
|
{GenAIProviderEnum.openai: FailingClient},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/genai/probe",
|
||||||
|
json={"provider": "openai"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert "message" in payload
|
||||||
|
|||||||
@ -1557,9 +1557,14 @@
|
|||||||
"searchPlaceholder": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
"addCustomLabel": "Add custom label...",
|
"addCustomLabel": "Add custom label...",
|
||||||
"genaiModel": {
|
"genaiModel": {
|
||||||
"placeholder": "Select model…",
|
"placeholder": "Select or enter a model…",
|
||||||
"search": "Search models…",
|
"search": "Search or enter a model…",
|
||||||
"noModels": "No models available"
|
"noModels": "No models available",
|
||||||
|
"available": "Available models",
|
||||||
|
"useCustom": "Use \"{{value}}\"",
|
||||||
|
"refresh": "Refresh models",
|
||||||
|
"probeFailed": "Failed to probe models",
|
||||||
|
"fetchedModels": "Successfully fetched model list"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalConfig": {
|
"globalConfig": {
|
||||||
|
|||||||
@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react";
|
|||||||
import type { WidgetProps } from "@rjsf/utils";
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import axios from "axios";
|
||||||
|
import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import { LuCheck } from "react-icons/lu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@ -19,9 +22,17 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||||
import { getSizedFieldClassName } from "../utils";
|
import { getSizedFieldClassName } from "../utils";
|
||||||
|
|
||||||
|
type ProbeResponse =
|
||||||
|
| { success: true; models: string[] }
|
||||||
|
| { success: false; message: string };
|
||||||
|
|
||||||
|
type ProbeStatus = "idle" | "probing" | "success" | "error";
|
||||||
|
|
||||||
|
const PROBE_SUCCESS_INDICATOR_MS = 3000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the provider config entry name from the RJSF widget id.
|
* Extract the provider config entry name from the RJSF widget id.
|
||||||
* Widget ids look like "root_myProvider_model".
|
* Widget ids look like "root_myProvider_model".
|
||||||
@ -41,6 +52,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
|||||||
const { id, value, disabled, readonly, onChange, options, registry } = props;
|
const { id, value, disabled, readonly, onChange, options, registry } = props;
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
|
||||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||||
const providerKey = useMemo(() => getProviderKey(id), [id]);
|
const providerKey = useMemo(() => getProviderKey(id), [id]);
|
||||||
@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}, [configFingerprint, mutateModels]);
|
}, [configFingerprint, mutateModels]);
|
||||||
|
|
||||||
const models = useMemo(() => {
|
const fetchedModels = useMemo(() => {
|
||||||
if (!allModels || !providerKey) return [];
|
if (!allModels || !providerKey) return [];
|
||||||
return allModels[providerKey] ?? [];
|
return allModels[providerKey] ?? [];
|
||||||
}, [allModels, providerKey]);
|
}, [allModels, providerKey]);
|
||||||
|
|
||||||
|
const [probeStatus, setProbeStatus] = useState<ProbeStatus>("idle");
|
||||||
|
const [probeError, setProbeError] = useState<string | null>(null);
|
||||||
|
const [probedModels, setProbedModels] = useState<string[] | null>(null);
|
||||||
|
const probeSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const probing = probeStatus === "probing";
|
||||||
|
|
||||||
|
// Reset probe results if the provider entry name changes
|
||||||
|
useEffect(() => {
|
||||||
|
setProbedModels(null);
|
||||||
|
setProbeError(null);
|
||||||
|
setProbeStatus("idle");
|
||||||
|
if (probeSuccessTimerRef.current) {
|
||||||
|
clearTimeout(probeSuccessTimerRef.current);
|
||||||
|
probeSuccessTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [providerKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (probeSuccessTimerRef.current) {
|
||||||
|
clearTimeout(probeSuccessTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const models = probedModels ?? fetchedModels;
|
||||||
|
|
||||||
|
const trimmedSearch = searchValue.trim();
|
||||||
|
const matchesFetched = useMemo(
|
||||||
|
() => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||||
|
[models, trimmedSearch],
|
||||||
|
);
|
||||||
|
const showCustomOption = trimmedSearch.length > 0 && !matchesFetched;
|
||||||
|
|
||||||
|
// Read the live form values for this provider so probe sends the user's
|
||||||
|
// in-flight edits, not the saved config (which may not exist yet).
|
||||||
|
const formEntry = useMemo<JsonObject | null>(() => {
|
||||||
|
if (!providerKey) return null;
|
||||||
|
const formData = formContext?.formData as JsonObject | undefined;
|
||||||
|
const entry = formData?.[providerKey];
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry as JsonObject;
|
||||||
|
}, [providerKey, formContext?.formData]);
|
||||||
|
|
||||||
|
const formProvider =
|
||||||
|
typeof formEntry?.provider === "string" ? formEntry.provider : null;
|
||||||
|
const canProbe = Boolean(formProvider) && !probing;
|
||||||
|
|
||||||
|
const probe = async () => {
|
||||||
|
if (!formEntry || !formProvider) return;
|
||||||
|
if (probeSuccessTimerRef.current) {
|
||||||
|
clearTimeout(probeSuccessTimerRef.current);
|
||||||
|
probeSuccessTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setProbeStatus("probing");
|
||||||
|
setProbeError(null);
|
||||||
|
try {
|
||||||
|
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||||
|
provider: formProvider,
|
||||||
|
api_key:
|
||||||
|
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||||
|
base_url:
|
||||||
|
typeof formEntry.base_url === "string" ? formEntry.base_url : null,
|
||||||
|
provider_options:
|
||||||
|
formEntry.provider_options &&
|
||||||
|
typeof formEntry.provider_options === "object" &&
|
||||||
|
!Array.isArray(formEntry.provider_options)
|
||||||
|
? (formEntry.provider_options as JsonObject)
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
if (res.data.success) {
|
||||||
|
setProbedModels(res.data.models);
|
||||||
|
setProbeStatus("success");
|
||||||
|
probeSuccessTimerRef.current = setTimeout(() => {
|
||||||
|
setProbeStatus("idle");
|
||||||
|
probeSuccessTimerRef.current = null;
|
||||||
|
}, PROBE_SUCCESS_INDICATOR_MS);
|
||||||
|
} else {
|
||||||
|
setProbedModels([]);
|
||||||
|
setProbeError(res.data.message);
|
||||||
|
setProbeStatus("error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setProbedModels(null);
|
||||||
|
setProbeError(
|
||||||
|
t("configForm.genaiModel.probeFailed", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Failed to probe models",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setProbeStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = (next: string) => {
|
||||||
|
onChange(next);
|
||||||
|
setSearchValue("");
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const currentLabel = typeof value === "string" && value ? value : undefined;
|
const currentLabel = typeof value === "string" && value ? value : undefined;
|
||||||
|
|
||||||
|
const refreshLabel = t("configForm.genaiModel.refresh", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Refresh models",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<div className="flex flex-col gap-1">
|
||||||
<PopoverTrigger asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Popover
|
||||||
id={id}
|
open={open}
|
||||||
type="button"
|
onOpenChange={(next) => {
|
||||||
variant="outline"
|
setOpen(next);
|
||||||
role="combobox"
|
if (!next) setSearchValue("");
|
||||||
aria-expanded={open}
|
}}
|
||||||
disabled={disabled || readonly}
|
|
||||||
className={cn(
|
|
||||||
"justify-between font-normal",
|
|
||||||
!currentLabel && "text-muted-foreground",
|
|
||||||
fieldClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{currentLabel ??
|
<PopoverTrigger asChild>
|
||||||
t("configForm.genaiModel.placeholder", {
|
<Button
|
||||||
ns: "views/settings",
|
id={id}
|
||||||
defaultValue: "Select model…",
|
type="button"
|
||||||
})}
|
variant="outline"
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
role="combobox"
|
||||||
</Button>
|
aria-expanded={open}
|
||||||
</PopoverTrigger>
|
disabled={disabled || readonly}
|
||||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
className={cn(
|
||||||
<Command>
|
"justify-between font-normal",
|
||||||
<CommandInput
|
!currentLabel && "text-muted-foreground",
|
||||||
placeholder={t("configForm.genaiModel.search", {
|
fieldClassName,
|
||||||
ns: "views/settings",
|
)}
|
||||||
defaultValue: "Search models…",
|
>
|
||||||
})}
|
{currentLabel ??
|
||||||
/>
|
t("configForm.genaiModel.placeholder", {
|
||||||
<CommandList>
|
|
||||||
{models.length > 0 ? (
|
|
||||||
<CommandGroup>
|
|
||||||
{models.map((model) => (
|
|
||||||
<CommandItem
|
|
||||||
key={model}
|
|
||||||
value={model}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(model);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
value === model ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{model}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t("configForm.genaiModel.noModels", {
|
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue: "No models available",
|
defaultValue: "Select or enter a model…",
|
||||||
})}
|
})}
|
||||||
</div>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
)}
|
</Button>
|
||||||
</CommandList>
|
</PopoverTrigger>
|
||||||
</Command>
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||||
</PopoverContent>
|
<Command>
|
||||||
</Popover>
|
<CommandInput
|
||||||
|
placeholder={t("configForm.genaiModel.search", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Search or enter a model…",
|
||||||
|
})}
|
||||||
|
value={searchValue}
|
||||||
|
onValueChange={setSearchValue}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && showCustomOption) {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(trimmedSearch);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{showCustomOption && (
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value={trimmedSearch}
|
||||||
|
onSelect={() => commit(trimmedSearch)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("configForm.genaiModel.useCustom", {
|
||||||
|
ns: "views/settings",
|
||||||
|
value: trimmedSearch,
|
||||||
|
defaultValue: 'Use "{{value}}"',
|
||||||
|
})}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{models.length > 0 ? (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("configForm.genaiModel.available", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Available models",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{models.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model}
|
||||||
|
value={model}
|
||||||
|
onSelect={() => commit(model)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === model ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{model}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
) : !showCustomOption ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("configForm.genaiModel.noModels", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "No models available",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
disabled={!canProbe || disabled || readonly}
|
||||||
|
onClick={probe}
|
||||||
|
title={refreshLabel}
|
||||||
|
aria-label={refreshLabel}
|
||||||
|
>
|
||||||
|
{probing ? (
|
||||||
|
<ActivityIndicator className="h-4 w-4" size={16} />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||||
|
probeStatus === "idle" || probeStatus === "probing"
|
||||||
|
? "opacity-0"
|
||||||
|
: "opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{probeStatus === "success" && (
|
||||||
|
<span className="flex items-center gap-1 text-success">
|
||||||
|
<LuCheck className="size-3.5" />
|
||||||
|
{t("configForm.genaiModel.fetchedModels", {
|
||||||
|
ns: "views/settings",
|
||||||
|
defaultValue: "Successfully fetched model list",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{probeStatus === "error" && probeError && (
|
||||||
|
<span className="text-destructive">{probeError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user