mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +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:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
tags:
|
||||
@ -7031,6 +7072,39 @@ components:
|
||||
"john_doe": ["face1.webp", "face2.jpg"],
|
||||
"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:
|
||||
properties:
|
||||
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.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
GenAIProbeBody,
|
||||
MediaSyncBody,
|
||||
)
|
||||
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 (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
get_current_media_sync_job,
|
||||
get_media_sync_job_by_id,
|
||||
@ -75,6 +77,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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(
|
||||
"/", 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())
|
||||
|
||||
|
||||
@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())])
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
|
||||
@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
|
||||
|
||||
class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel):
|
||||
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):
|
||||
password: str
|
||||
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).",
|
||||
)
|
||||
model: str = Field(
|
||||
default="gpt-4o",
|
||||
default="",
|
||||
title="Model",
|
||||
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:
|
||||
"""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.timeout = timeout
|
||||
self.validate_model = validate_model
|
||||
self.provider = self._init_provider()
|
||||
|
||||
def generate_review_description(
|
||||
|
||||
@ -150,6 +150,10 @@ class LlamaCppClient(GenAIClient):
|
||||
else:
|
||||
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
|
||||
info = self._get_model_info(base_url, configured_model)
|
||||
|
||||
|
||||
@ -118,6 +118,9 @@ class OllamaClient(GenAIClient):
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
if not self.validate_model:
|
||||
# Probe path
|
||||
return client
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
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.stats.emitter import StatsEmitter
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
@ -71,3 +74,94 @@ class TestHttpApp(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
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...",
|
||||
"addCustomLabel": "Add custom label...",
|
||||
"genaiModel": {
|
||||
"placeholder": "Select model…",
|
||||
"search": "Search models…",
|
||||
"noModels": "No models available"
|
||||
"placeholder": "Select or enter a model…",
|
||||
"search": "Search or enter a model…",
|
||||
"noModels": "No models available",
|
||||
"available": "Available models",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"refresh": "Refresh models",
|
||||
"probeFailed": "Failed to probe models",
|
||||
"fetchedModels": "Successfully fetched model list"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
|
||||
@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@ -19,9 +22,17 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
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.
|
||||
* 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 { t } = useTranslation(["views/settings"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
const providerKey = useMemo(() => getProviderKey(id), [id]);
|
||||
@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
}
|
||||
}, [configFingerprint, mutateModels]);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const fetchedModels = useMemo(() => {
|
||||
if (!allModels || !providerKey) return [];
|
||||
return 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 refreshLabel = t("configForm.genaiModel.refresh", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Refresh models",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchValue("");
|
||||
}}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select model…",
|
||||
})}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.genaiModel.search", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search models…",
|
||||
})}
|
||||
/>
|
||||
<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", {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No models available",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<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