mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Miscellaneous fixes (#23258)
* render orphaned filter entries as collapsibles instead of the Key/Value editor * Symlink for various AI files * change replay confg dialog to platform aware sheet * change agents title * fix test * tweak collapsible * remove camera ui section in settings no point to having it anymore with profiles and camera management settings * fix admin response cache leak to non-admin users via nginx proxy_cache * add model fetcher endpoint for genai config ui --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
03f4f76b72
commit
8ea46e7c6c
439
.github/copilot-instructions.md
vendored
439
.github/copilot-instructions.md
vendored
@ -1,439 +0,0 @@
|
|||||||
# GitHub Copilot Instructions for Frigate NVR
|
|
||||||
|
|
||||||
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Frigate NVR is a realtime object detection system for IP cameras that uses:
|
|
||||||
|
|
||||||
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
|
|
||||||
- **Frontend**: React with TypeScript, Vite, TailwindCSS
|
|
||||||
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
|
|
||||||
- **Focus**: Minimal resource usage with maximum performance
|
|
||||||
|
|
||||||
## Code Review Guidelines
|
|
||||||
|
|
||||||
When reviewing code, do NOT comment on:
|
|
||||||
|
|
||||||
- Missing imports - Static analysis tooling catches these
|
|
||||||
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
|
|
||||||
- Minor style inconsistencies already enforced by linters
|
|
||||||
|
|
||||||
## Python Backend Standards
|
|
||||||
|
|
||||||
### Python Requirements
|
|
||||||
|
|
||||||
- **Compatibility**: Python 3.13+
|
|
||||||
- **Language Features**: Use modern Python features:
|
|
||||||
- Pattern matching
|
|
||||||
- Type hints (comprehensive typing preferred)
|
|
||||||
- f-strings (preferred over `%` or `.format()`)
|
|
||||||
- Dataclasses
|
|
||||||
- Async/await patterns
|
|
||||||
|
|
||||||
### Code Quality Standards
|
|
||||||
|
|
||||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
|
||||||
- **Linting**: Ruff with rules defined in project config
|
|
||||||
- **Type Checking**: Use type hints consistently
|
|
||||||
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
|
|
||||||
- **Language**: American English for all code, comments, and documentation
|
|
||||||
|
|
||||||
### Logging Standards
|
|
||||||
|
|
||||||
- **Logger Pattern**: Use module-level logger
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Format Guidelines**:
|
|
||||||
- No periods at end of log messages
|
|
||||||
- No sensitive data (keys, tokens, passwords)
|
|
||||||
- Use lazy logging: `logger.debug("Message with %s", variable)`
|
|
||||||
- **Log Levels**:
|
|
||||||
- `debug`: Development and troubleshooting information
|
|
||||||
- `info`: Important runtime events (startup, shutdown, state changes)
|
|
||||||
- `warning`: Recoverable issues that should be addressed
|
|
||||||
- `error`: Errors that affect functionality but don't crash the app
|
|
||||||
- `exception`: Use in except blocks to include traceback
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- **Exception Types**: Choose most specific exception available
|
|
||||||
- **Try/Catch Best Practices**:
|
|
||||||
- Only wrap code that can throw exceptions
|
|
||||||
- Keep try blocks minimal - process data after the try/except
|
|
||||||
- Avoid bare exceptions except in background tasks
|
|
||||||
|
|
||||||
Bad pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
data = await device.get_data() # Can throw
|
|
||||||
# ❌ Don't process data inside try block
|
|
||||||
processed = data.get("value", 0) * 100
|
|
||||||
result = processed
|
|
||||||
except DeviceError:
|
|
||||||
logger.error("Failed to get data")
|
|
||||||
```
|
|
||||||
|
|
||||||
Good pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
data = await device.get_data() # Can throw
|
|
||||||
except DeviceError:
|
|
||||||
logger.error("Failed to get data")
|
|
||||||
return
|
|
||||||
|
|
||||||
# ✅ Process data outside try block
|
|
||||||
processed = data.get("value", 0) * 100
|
|
||||||
result = processed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Async Programming
|
|
||||||
|
|
||||||
- **External I/O**: All external I/O operations must be async
|
|
||||||
- **Best Practices**:
|
|
||||||
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
|
|
||||||
- Avoid awaiting in loops - use `asyncio.gather()` instead
|
|
||||||
- No blocking calls in async functions
|
|
||||||
- Use `asyncio.create_task()` for background operations
|
|
||||||
- **Thread Safety**: Use proper synchronization for shared state
|
|
||||||
|
|
||||||
### Documentation Standards
|
|
||||||
|
|
||||||
- **Module Docstrings**: Concise descriptions at top of files
|
|
||||||
```python
|
|
||||||
"""Utilities for motion detection and analysis."""
|
|
||||||
```
|
|
||||||
- **Function Docstrings**: Required for public functions and methods
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def process_frame(frame: ndarray, config: Config) -> Detection:
|
|
||||||
"""Process a video frame for object detection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: The video frame as numpy array
|
|
||||||
config: Detection configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Detection results with bounding boxes
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Comment Style**:
|
|
||||||
- Explain the "why" not just the "what"
|
|
||||||
- Keep lines under 88 characters when possible
|
|
||||||
- Use clear, descriptive comments
|
|
||||||
|
|
||||||
### File Organization
|
|
||||||
|
|
||||||
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
|
|
||||||
- **Configuration**: `frigate/config/` - Configuration parsing and validation
|
|
||||||
- **Detectors**: `frigate/detectors/` - Object detection backends
|
|
||||||
- **Events**: `frigate/events/` - Event management and storage
|
|
||||||
- **Utilities**: `frigate/util/` - Shared utility functions
|
|
||||||
|
|
||||||
## Frontend (React/TypeScript) Standards
|
|
||||||
|
|
||||||
### Internationalization (i18n)
|
|
||||||
|
|
||||||
- **CRITICAL**: Never write user-facing strings directly in components
|
|
||||||
- **Always use react-i18next**: Import and use the `t()` function
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { t } = useTranslation(["views/live"]);
|
|
||||||
return <div>{t("camera_not_found")}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
|
|
||||||
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
|
||||||
- **Formatting**: Prettier with Tailwind CSS plugin
|
|
||||||
- **Type Safety**: TypeScript strict mode enabled
|
|
||||||
|
|
||||||
### Component Patterns
|
|
||||||
|
|
||||||
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
|
|
||||||
- **Styling**: TailwindCSS with `cn()` utility for class merging
|
|
||||||
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
|
|
||||||
- **Data Fetching**: Custom hooks with proper loading and error states
|
|
||||||
|
|
||||||
### ESLint Rules
|
|
||||||
|
|
||||||
Key rules enforced:
|
|
||||||
|
|
||||||
- `react-hooks/rules-of-hooks`: error
|
|
||||||
- `react-hooks/exhaustive-deps`: error
|
|
||||||
- `no-console`: error (use proper logging or remove)
|
|
||||||
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
|
|
||||||
- Unused variables must be prefixed with `_`
|
|
||||||
- Comma dangles required for multiline objects/arrays
|
|
||||||
|
|
||||||
### File Organization
|
|
||||||
|
|
||||||
- **Pages**: `web/src/pages/` - Route components
|
|
||||||
- **Views**: `web/src/views/` - Complex view components
|
|
||||||
- **Components**: `web/src/components/` - Reusable components
|
|
||||||
- **Hooks**: `web/src/hooks/` - Custom React hooks
|
|
||||||
- **API**: `web/src/api/` - API client functions
|
|
||||||
- **Types**: `web/src/types/` - TypeScript type definitions
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
### Backend Testing
|
|
||||||
|
|
||||||
- **Framework**: Python unittest
|
|
||||||
- **Run Command**: `python3 -u -m unittest`
|
|
||||||
- **Location**: `frigate/test/`
|
|
||||||
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
|
||||||
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
|
||||||
```python
|
|
||||||
class TestMotionDetection(unittest.TestCase):
|
|
||||||
def test_detects_motion_above_threshold(self):
|
|
||||||
# Test implementation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Best Practices
|
|
||||||
|
|
||||||
- Always have a way to test your work and confirm your changes
|
|
||||||
- Write tests for bug fixes to prevent regressions
|
|
||||||
- Test edge cases and error conditions
|
|
||||||
- Mock external dependencies (cameras, APIs, hardware)
|
|
||||||
- Use fixtures for test data
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Python Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
python3 -u -m unittest
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
|
||||||
|
|
||||||
# Check formatting (Ruff)
|
|
||||||
ruff format --check frigate/
|
|
||||||
|
|
||||||
# Apply formatting
|
|
||||||
ruff format frigate/
|
|
||||||
|
|
||||||
# Run linter
|
|
||||||
ruff check frigate/
|
|
||||||
|
|
||||||
# Type check
|
|
||||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend (from web/ directory)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start dev server (AI agents should never run this directly unless asked)
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Run linter
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
# Fix linting issues
|
|
||||||
npm run lint:fix
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
npm run prettier:write
|
|
||||||
|
|
||||||
# E2E: first-time setup
|
|
||||||
npm install
|
|
||||||
npx playwright install chromium
|
|
||||||
|
|
||||||
# E2E: build the app and run all tests
|
|
||||||
npm run e2e:build && npm run e2e
|
|
||||||
|
|
||||||
# E2E: interactive UI for debugging
|
|
||||||
npm run e2e:ui
|
|
||||||
|
|
||||||
# E2E: run a specific spec
|
|
||||||
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
|
|
||||||
|
|
||||||
# E2E: filter by name, or run only desktop/mobile
|
|
||||||
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
|
|
||||||
npx playwright test --config e2e/playwright.config.ts --project=desktop
|
|
||||||
|
|
||||||
# E2E: regenerate mock data after backend model changes (from repo root)
|
|
||||||
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
|
|
||||||
|
|
||||||
# Regenerate config translations from Pydantic models — outputs to
|
|
||||||
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
|
|
||||||
# JSON files by hand; change the Pydantic field title/description and
|
|
||||||
# re-run this script. (from repo root)
|
|
||||||
python3 generate_config_translations.py
|
|
||||||
|
|
||||||
# Extract i18n keys from source into the locale files after adding
|
|
||||||
# new t() calls. Use the :ci variant to verify the locale files are
|
|
||||||
# in sync with source (fails if extraction would change anything).
|
|
||||||
npm run i18n:extract
|
|
||||||
npm run i18n:extract:ci
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Development
|
|
||||||
|
|
||||||
AI agents should never run these commands directly unless instructed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build local image
|
|
||||||
make local
|
|
||||||
|
|
||||||
# Build debug image
|
|
||||||
make debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### API Endpoint Pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from frigate.api.defs.tags import Tags
|
|
||||||
|
|
||||||
router = APIRouter(tags=[Tags.Events])
|
|
||||||
|
|
||||||
@router.get("/events")
|
|
||||||
async def get_events(request: Request, limit: int = 100):
|
|
||||||
"""Retrieve events from the database."""
|
|
||||||
# Implementation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Access
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Access Frigate configuration
|
|
||||||
config: FrigateConfig = request.app.frigate_config
|
|
||||||
camera_config = config.cameras["front_door"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Queries
|
|
||||||
|
|
||||||
```python
|
|
||||||
from frigate.models import Event
|
|
||||||
|
|
||||||
# Use Peewee ORM for database access
|
|
||||||
events = (
|
|
||||||
Event.select()
|
|
||||||
.where(Event.camera == camera_name)
|
|
||||||
.order_by(Event.start_time.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Anti-Patterns to Avoid
|
|
||||||
|
|
||||||
### ❌ Avoid These
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Blocking operations in async functions
|
|
||||||
data = requests.get(url) # ❌ Use async HTTP client
|
|
||||||
time.sleep(5) # ❌ Use asyncio.sleep()
|
|
||||||
|
|
||||||
# Hardcoded strings in React components
|
|
||||||
<div>Camera not found</div> # ❌ Use t("camera_not_found")
|
|
||||||
|
|
||||||
# Missing error handling
|
|
||||||
data = await api.get_data() # ❌ No exception handling
|
|
||||||
|
|
||||||
# Bare exceptions in regular code
|
|
||||||
try:
|
|
||||||
value = await sensor.read()
|
|
||||||
except Exception: # ❌ Too broad
|
|
||||||
logger.error("Failed")
|
|
||||||
|
|
||||||
# Returning exceptions in JSON responses
|
|
||||||
except ValueError as e:
|
|
||||||
return JSONResponse(
|
|
||||||
content={"success": False, "message": str(e)},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Use These Instead
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Async operations
|
|
||||||
import aiohttp
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.get(url) as response:
|
|
||||||
data = await response.json()
|
|
||||||
|
|
||||||
await asyncio.sleep(5) # ✅ Non-blocking
|
|
||||||
|
|
||||||
# Translatable strings in React
|
|
||||||
const { t } = useTranslation();
|
|
||||||
<div>{t("camera_not_found")}</div> # ✅ Translatable
|
|
||||||
|
|
||||||
# Proper error handling
|
|
||||||
try:
|
|
||||||
data = await api.get_data()
|
|
||||||
except ApiException as err:
|
|
||||||
logger.error("API error: %s", err)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Specific exceptions
|
|
||||||
try:
|
|
||||||
value = await sensor.read()
|
|
||||||
except SensorException as err: # ✅ Specific
|
|
||||||
logger.exception("Failed to read sensor")
|
|
||||||
|
|
||||||
# Safe error responses
|
|
||||||
except ValueError:
|
|
||||||
logger.exception("Invalid parameters for API request")
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Invalid request parameters",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebSocket Broadcasts
|
|
||||||
|
|
||||||
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
|
|
||||||
|
|
||||||
## Project-Specific Conventions
|
|
||||||
|
|
||||||
### Configuration Files
|
|
||||||
|
|
||||||
- Main config: `config/config.yml`
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
- Backend code: `frigate/`
|
|
||||||
- Frontend code: `web/`
|
|
||||||
- Docker files: `docker/`
|
|
||||||
- Documentation: `docs/`
|
|
||||||
- Database migrations: `migrations/`
|
|
||||||
|
|
||||||
### Code Style Conformance
|
|
||||||
|
|
||||||
Always conform new and refactored code to the existing coding style in the project:
|
|
||||||
|
|
||||||
- Follow established patterns in similar files
|
|
||||||
- Match indentation and formatting of surrounding code
|
|
||||||
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
|
|
||||||
- Maintain the same level of verbosity in comments and docstrings
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- Documentation: https://docs.frigate.video
|
|
||||||
- Main Repository: https://github.com/blakeblackshear/frigate
|
|
||||||
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
|
|
||||||
1
.github/copilot-instructions.md
vendored
Symbolic link
1
.github/copilot-instructions.md
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
AGENTS.md
|
||||||
439
AGENTS.md
Normal file
439
AGENTS.md
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
# Agent Instructions for Frigate NVR
|
||||||
|
|
||||||
|
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Frigate NVR is a realtime object detection system for IP cameras that uses:
|
||||||
|
|
||||||
|
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
|
||||||
|
- **Frontend**: React with TypeScript, Vite, TailwindCSS
|
||||||
|
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
|
||||||
|
- **Focus**: Minimal resource usage with maximum performance
|
||||||
|
|
||||||
|
## Code Review Guidelines
|
||||||
|
|
||||||
|
When reviewing code, do NOT comment on:
|
||||||
|
|
||||||
|
- Missing imports - Static analysis tooling catches these
|
||||||
|
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
|
||||||
|
- Minor style inconsistencies already enforced by linters
|
||||||
|
|
||||||
|
## Python Backend Standards
|
||||||
|
|
||||||
|
### Python Requirements
|
||||||
|
|
||||||
|
- **Compatibility**: Python 3.13+
|
||||||
|
- **Language Features**: Use modern Python features:
|
||||||
|
- Pattern matching
|
||||||
|
- Type hints (comprehensive typing preferred)
|
||||||
|
- f-strings (preferred over `%` or `.format()`)
|
||||||
|
- Dataclasses
|
||||||
|
- Async/await patterns
|
||||||
|
|
||||||
|
### Code Quality Standards
|
||||||
|
|
||||||
|
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||||
|
- **Linting**: Ruff with rules defined in project config
|
||||||
|
- **Type Checking**: Use type hints consistently
|
||||||
|
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
|
||||||
|
- **Language**: American English for all code, comments, and documentation
|
||||||
|
|
||||||
|
### Logging Standards
|
||||||
|
|
||||||
|
- **Logger Pattern**: Use module-level logger
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Format Guidelines**:
|
||||||
|
- No periods at end of log messages
|
||||||
|
- No sensitive data (keys, tokens, passwords)
|
||||||
|
- Use lazy logging: `logger.debug("Message with %s", variable)`
|
||||||
|
- **Log Levels**:
|
||||||
|
- `debug`: Development and troubleshooting information
|
||||||
|
- `info`: Important runtime events (startup, shutdown, state changes)
|
||||||
|
- `warning`: Recoverable issues that should be addressed
|
||||||
|
- `error`: Errors that affect functionality but don't crash the app
|
||||||
|
- `exception`: Use in except blocks to include traceback
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Exception Types**: Choose most specific exception available
|
||||||
|
- **Try/Catch Best Practices**:
|
||||||
|
- Only wrap code that can throw exceptions
|
||||||
|
- Keep try blocks minimal - process data after the try/except
|
||||||
|
- Avoid bare exceptions except in background tasks
|
||||||
|
|
||||||
|
Bad pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
data = await device.get_data() # Can throw
|
||||||
|
# ❌ Don't process data inside try block
|
||||||
|
processed = data.get("value", 0) * 100
|
||||||
|
result = processed
|
||||||
|
except DeviceError:
|
||||||
|
logger.error("Failed to get data")
|
||||||
|
```
|
||||||
|
|
||||||
|
Good pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
data = await device.get_data() # Can throw
|
||||||
|
except DeviceError:
|
||||||
|
logger.error("Failed to get data")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ✅ Process data outside try block
|
||||||
|
processed = data.get("value", 0) * 100
|
||||||
|
result = processed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Programming
|
||||||
|
|
||||||
|
- **External I/O**: All external I/O operations must be async
|
||||||
|
- **Best Practices**:
|
||||||
|
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
|
||||||
|
- Avoid awaiting in loops - use `asyncio.gather()` instead
|
||||||
|
- No blocking calls in async functions
|
||||||
|
- Use `asyncio.create_task()` for background operations
|
||||||
|
- **Thread Safety**: Use proper synchronization for shared state
|
||||||
|
|
||||||
|
### Documentation Standards
|
||||||
|
|
||||||
|
- **Module Docstrings**: Concise descriptions at top of files
|
||||||
|
```python
|
||||||
|
"""Utilities for motion detection and analysis."""
|
||||||
|
```
|
||||||
|
- **Function Docstrings**: Required for public functions and methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def process_frame(frame: ndarray, config: Config) -> Detection:
|
||||||
|
"""Process a video frame for object detection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The video frame as numpy array
|
||||||
|
config: Detection configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detection results with bounding boxes
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Comment Style**:
|
||||||
|
- Explain the "why" not just the "what"
|
||||||
|
- Keep lines under 88 characters when possible
|
||||||
|
- Use clear, descriptive comments
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
|
||||||
|
- **Configuration**: `frigate/config/` - Configuration parsing and validation
|
||||||
|
- **Detectors**: `frigate/detectors/` - Object detection backends
|
||||||
|
- **Events**: `frigate/events/` - Event management and storage
|
||||||
|
- **Utilities**: `frigate/util/` - Shared utility functions
|
||||||
|
|
||||||
|
## Frontend (React/TypeScript) Standards
|
||||||
|
|
||||||
|
### Internationalization (i18n)
|
||||||
|
|
||||||
|
- **CRITICAL**: Never write user-facing strings directly in components
|
||||||
|
- **Always use react-i18next**: Import and use the `t()` function
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { t } = useTranslation(["views/live"]);
|
||||||
|
return <div>{t("camera_not_found")}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
|
||||||
|
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
||||||
|
- **Formatting**: Prettier with Tailwind CSS plugin
|
||||||
|
- **Type Safety**: TypeScript strict mode enabled
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
|
||||||
|
- **Styling**: TailwindCSS with `cn()` utility for class merging
|
||||||
|
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
|
||||||
|
- **Data Fetching**: Custom hooks with proper loading and error states
|
||||||
|
|
||||||
|
### ESLint Rules
|
||||||
|
|
||||||
|
Key rules enforced:
|
||||||
|
|
||||||
|
- `react-hooks/rules-of-hooks`: error
|
||||||
|
- `react-hooks/exhaustive-deps`: error
|
||||||
|
- `no-console`: error (use proper logging or remove)
|
||||||
|
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
|
||||||
|
- Unused variables must be prefixed with `_`
|
||||||
|
- Comma dangles required for multiline objects/arrays
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
- **Pages**: `web/src/pages/` - Route components
|
||||||
|
- **Views**: `web/src/views/` - Complex view components
|
||||||
|
- **Components**: `web/src/components/` - Reusable components
|
||||||
|
- **Hooks**: `web/src/hooks/` - Custom React hooks
|
||||||
|
- **API**: `web/src/api/` - API client functions
|
||||||
|
- **Types**: `web/src/types/` - TypeScript type definitions
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Backend Testing
|
||||||
|
|
||||||
|
- **Framework**: Python unittest
|
||||||
|
- **Run Command**: `python3 -u -m unittest`
|
||||||
|
- **Location**: `frigate/test/`
|
||||||
|
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
||||||
|
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
||||||
|
```python
|
||||||
|
class TestMotionDetection(unittest.TestCase):
|
||||||
|
def test_detects_motion_above_threshold(self):
|
||||||
|
# Test implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Best Practices
|
||||||
|
|
||||||
|
- Always have a way to test your work and confirm your changes
|
||||||
|
- Write tests for bug fixes to prevent regressions
|
||||||
|
- Test edge cases and error conditions
|
||||||
|
- Mock external dependencies (cameras, APIs, hardware)
|
||||||
|
- Use fixtures for test data
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Python Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
python3 -u -m unittest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
||||||
|
|
||||||
|
# Check formatting (Ruff)
|
||||||
|
ruff format --check frigate/
|
||||||
|
|
||||||
|
# Apply formatting
|
||||||
|
ruff format frigate/
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
ruff check frigate/
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (from web/ directory)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server (AI agents should never run this directly unless asked)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
npm run lint:fix
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
npm run prettier:write
|
||||||
|
|
||||||
|
# E2E: first-time setup
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium
|
||||||
|
|
||||||
|
# E2E: build the app and run all tests
|
||||||
|
npm run e2e:build && npm run e2e
|
||||||
|
|
||||||
|
# E2E: interactive UI for debugging
|
||||||
|
npm run e2e:ui
|
||||||
|
|
||||||
|
# E2E: run a specific spec
|
||||||
|
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
|
||||||
|
|
||||||
|
# E2E: filter by name, or run only desktop/mobile
|
||||||
|
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
|
||||||
|
npx playwright test --config e2e/playwright.config.ts --project=desktop
|
||||||
|
|
||||||
|
# E2E: regenerate mock data after backend model changes (from repo root)
|
||||||
|
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
|
||||||
|
|
||||||
|
# Regenerate config translations from Pydantic models — outputs to
|
||||||
|
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
|
||||||
|
# JSON files by hand; change the Pydantic field title/description and
|
||||||
|
# re-run this script. (from repo root)
|
||||||
|
python3 generate_config_translations.py
|
||||||
|
|
||||||
|
# Extract i18n keys from source into the locale files after adding
|
||||||
|
# new t() calls. Use the :ci variant to verify the locale files are
|
||||||
|
# in sync with source (fails if extraction would change anything).
|
||||||
|
npm run i18n:extract
|
||||||
|
npm run i18n:extract:ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Development
|
||||||
|
|
||||||
|
AI agents should never run these commands directly unless instructed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build local image
|
||||||
|
make local
|
||||||
|
|
||||||
|
# Build debug image
|
||||||
|
make debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### API Endpoint Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from frigate.api.defs.tags import Tags
|
||||||
|
|
||||||
|
router = APIRouter(tags=[Tags.Events])
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def get_events(request: Request, limit: int = 100):
|
||||||
|
"""Retrieve events from the database."""
|
||||||
|
# Implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Access
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Access Frigate configuration
|
||||||
|
config: FrigateConfig = request.app.frigate_config
|
||||||
|
camera_config = config.cameras["front_door"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
```python
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
|
# Use Peewee ORM for database access
|
||||||
|
events = (
|
||||||
|
Event.select()
|
||||||
|
.where(Event.camera == camera_name)
|
||||||
|
.order_by(Event.start_time.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### ❌ Avoid These
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Blocking operations in async functions
|
||||||
|
data = requests.get(url) # ❌ Use async HTTP client
|
||||||
|
time.sleep(5) # ❌ Use asyncio.sleep()
|
||||||
|
|
||||||
|
# Hardcoded strings in React components
|
||||||
|
<div>Camera not found</div> # ❌ Use t("camera_not_found")
|
||||||
|
|
||||||
|
# Missing error handling
|
||||||
|
data = await api.get_data() # ❌ No exception handling
|
||||||
|
|
||||||
|
# Bare exceptions in regular code
|
||||||
|
try:
|
||||||
|
value = await sensor.read()
|
||||||
|
except Exception: # ❌ Too broad
|
||||||
|
logger.error("Failed")
|
||||||
|
|
||||||
|
# Returning exceptions in JSON responses
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": str(e)},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Use These Instead
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Async operations
|
||||||
|
import aiohttp
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
|
await asyncio.sleep(5) # ✅ Non-blocking
|
||||||
|
|
||||||
|
# Translatable strings in React
|
||||||
|
const { t } = useTranslation();
|
||||||
|
<div>{t("camera_not_found")}</div> # ✅ Translatable
|
||||||
|
|
||||||
|
# Proper error handling
|
||||||
|
try:
|
||||||
|
data = await api.get_data()
|
||||||
|
except ApiException as err:
|
||||||
|
logger.error("API error: %s", err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Specific exceptions
|
||||||
|
try:
|
||||||
|
value = await sensor.read()
|
||||||
|
except SensorException as err: # ✅ Specific
|
||||||
|
logger.exception("Failed to read sensor")
|
||||||
|
|
||||||
|
# Safe error responses
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("Invalid parameters for API request")
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Invalid request parameters",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Broadcasts
|
||||||
|
|
||||||
|
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
|
||||||
|
|
||||||
|
## Project-Specific Conventions
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- Main config: `config/config.yml`
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
- Backend code: `frigate/`
|
||||||
|
- Frontend code: `web/`
|
||||||
|
- Docker files: `docker/`
|
||||||
|
- Documentation: `docs/`
|
||||||
|
- Database migrations: `migrations/`
|
||||||
|
|
||||||
|
### Code Style Conformance
|
||||||
|
|
||||||
|
Always conform new and refactored code to the existing coding style in the project:
|
||||||
|
|
||||||
|
- Follow established patterns in similar files
|
||||||
|
- Match indentation and formatting of surrounding code
|
||||||
|
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
|
||||||
|
- Maintain the same level of verbosity in comments and docstrings
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- Documentation: https://docs.frigate.video
|
||||||
|
- Main Repository: https://github.com/blakeblackshear/frigate
|
||||||
|
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
|
||||||
@ -252,6 +252,7 @@ http {
|
|||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
|
|
||||||
proxy_cache api_cache;
|
proxy_cache api_cache;
|
||||||
|
proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
|
||||||
proxy_cache_lock on;
|
proxy_cache_lock on;
|
||||||
proxy_cache_use_stale updating;
|
proxy_cache_use_stale updating;
|
||||||
proxy_cache_valid 200 5s;
|
proxy_cache_valid 200 5s;
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@ -129,8 +129,14 @@ test.describe("Replay — active session @medium", () => {
|
|||||||
);
|
);
|
||||||
await actionGroup.first().click();
|
await actionGroup.first().click();
|
||||||
|
|
||||||
const dialog = frigateApp.page.getByRole("dialog");
|
// On mobile PlatformAwareSheet renders a MobilePage (full-screen panel)
|
||||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
// instead of a Radix Dialog, so assert the panel title heading is visible.
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: /^Configuration$/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Objects tab renders with the camera_activity objects list", async ({
|
test("Objects tab renders with the camera_activity objects list", async ({
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -1288,11 +1288,6 @@ export function ConfigSection({
|
|||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<div className="flex cursor-pointer items-center justify-between">
|
<div className="flex cursor-pointer items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isOpen ? (
|
|
||||||
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator &&
|
||||||
effectiveLevel === "camera" &&
|
effectiveLevel === "camera" &&
|
||||||
@ -1323,12 +1318,17 @@ export function ConfigSection({
|
|||||||
})}
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isOpen ? (
|
||||||
|
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="pl-7">{sectionContent}</div>
|
<div className="pl-0">{sectionContent}</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@ -171,7 +171,20 @@ function modifyObjectsSchema(
|
|||||||
ctx.fullConfig.objects?.track ??
|
ctx.fullConfig.objects?.track ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
if (track.length === 0) return schema;
|
// Also promote any label that has a saved filter entry but isn't in
|
||||||
|
// `track` (e.g. the user toggled an object off but left a customized
|
||||||
|
// filter in YAML). Without this, RJSF falls back to the additional-
|
||||||
|
// properties Key/Value editor for those orphans.
|
||||||
|
const filtersSaved =
|
||||||
|
(ctx.level !== "global"
|
||||||
|
? ctx.fullCameraConfig?.objects?.filters
|
||||||
|
: undefined) ??
|
||||||
|
ctx.fullConfig.objects?.filters ??
|
||||||
|
{};
|
||||||
|
|
||||||
|
if (track.length === 0 && Object.keys(filtersSaved).length === 0) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
const schemaProperties = isJsonObject(
|
const schemaProperties = isJsonObject(
|
||||||
(schema as { properties?: unknown }).properties,
|
(schema as { properties?: unknown }).properties,
|
||||||
@ -199,16 +212,27 @@ function modifyObjectsSchema(
|
|||||||
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
|
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// Promote every tracked label to an explicit property entry so RJSF
|
// Promote every tracked label (and any orphaned filter entry) to an
|
||||||
// renders it as a normal collapsible (no additionalProperties key/value
|
// explicit property entry so RJSF renders it as a normal collapsible
|
||||||
// editor UI). Attribute labels get a restricted shape with only
|
// (no additionalProperties key/value editor UI). Attribute labels get a
|
||||||
// `min_score`; non-attribute labels get the full FilterConfig. Sorted
|
// restricted shape with only `min_score`/`min_area`/`max_area`;
|
||||||
// alphabetically so the filter collapsibles match the order of the
|
// non-attribute labels get the full FilterConfig. Sorted alphabetically
|
||||||
// sibling `track` switches.
|
// so the filter collapsibles match the order of the sibling `track`
|
||||||
const sortedTrackedLabels = track
|
// switches.
|
||||||
.filter((label): label is string => typeof label === "string")
|
const labelsToPromote = new Set<string>();
|
||||||
.slice()
|
for (const label of track) {
|
||||||
.sort((a, b) => a.localeCompare(b));
|
if (typeof label === "string") labelsToPromote.add(label);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(filtersSaved)) {
|
||||||
|
// Skip attribute labels that aren't tracked — those are hidden
|
||||||
|
// entirely via hideAttributeFilters; promoting them would surface a
|
||||||
|
// collapsible we then have to hide separately.
|
||||||
|
if (attributeSet.has(key) && !labelsToPromote.has(key)) continue;
|
||||||
|
labelsToPromote.add(key);
|
||||||
|
}
|
||||||
|
const sortedTrackedLabels = [...labelsToPromote].sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
const updatedFilterProperties: Record<string, RJSFSchema> = {
|
const updatedFilterProperties: Record<string, RJSFSchema> = {
|
||||||
...existingProperties,
|
...existingProperties,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,13 +27,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -333,15 +327,64 @@ export default function Replay() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<PlatformAwareSheet
|
||||||
variant="outline"
|
trigger={
|
||||||
size="sm"
|
<Button
|
||||||
className="flex items-center gap-2"
|
variant="outline"
|
||||||
onClick={() => setConfigDialogOpen(true)}
|
size="sm"
|
||||||
>
|
className="flex items-center gap-2"
|
||||||
<LuSettings className="size-4" />
|
>
|
||||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
<LuSettings className="size-4" />
|
||||||
</Button>
|
<span className="hidden md:inline">
|
||||||
|
{t("page.configuration")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={t("page.configuration")}
|
||||||
|
titleClassName="text-lg font-semibold"
|
||||||
|
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
<p className="mb-5 text-sm text-muted-foreground">
|
||||||
|
{t("page.configurationDesc")}
|
||||||
|
</p>
|
||||||
|
{configSchema == null ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSectionTemplate
|
||||||
|
sectionKey="motion"
|
||||||
|
level="replay"
|
||||||
|
cameraName={status.replay_camera ?? undefined}
|
||||||
|
skipSave
|
||||||
|
noStickyButtons
|
||||||
|
requiresRestart={false}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
showTitle
|
||||||
|
showOverrideIndicator={false}
|
||||||
|
/>
|
||||||
|
<ConfigSectionTemplate
|
||||||
|
sectionKey="objects"
|
||||||
|
level="replay"
|
||||||
|
cameraName={status.replay_camera ?? undefined}
|
||||||
|
skipSave
|
||||||
|
noStickyButtons
|
||||||
|
requiresRestart={false}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
showTitle
|
||||||
|
showOverrideIndicator={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
open={configDialogOpen}
|
||||||
|
onOpenChange={setConfigDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
@ -644,49 +687,6 @@ export default function Replay() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
|
|
||||||
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("page.configuration")}</DialogTitle>
|
|
||||||
<DialogDescription className="mb-5">
|
|
||||||
{t("page.configurationDesc")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{configSchema == null ? (
|
|
||||||
<div className="flex h-40 items-center justify-center">
|
|
||||||
<ActivityIndicator />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ConfigSectionTemplate
|
|
||||||
sectionKey="motion"
|
|
||||||
level="replay"
|
|
||||||
cameraName={status.replay_camera ?? undefined}
|
|
||||||
skipSave
|
|
||||||
noStickyButtons
|
|
||||||
requiresRestart={false}
|
|
||||||
collapsible
|
|
||||||
defaultCollapsed={false}
|
|
||||||
showTitle
|
|
||||||
showOverrideIndicator={false}
|
|
||||||
/>
|
|
||||||
<ConfigSectionTemplate
|
|
||||||
sectionKey="objects"
|
|
||||||
level="replay"
|
|
||||||
cameraName={status.replay_camera ?? undefined}
|
|
||||||
skipSave
|
|
||||||
noStickyButtons
|
|
||||||
requiresRestart={false}
|
|
||||||
collapsible
|
|
||||||
defaultCollapsed={false}
|
|
||||||
showTitle
|
|
||||||
showOverrideIndicator={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -162,7 +162,6 @@ const allSettingsViews = [
|
|||||||
"cameraLpr",
|
"cameraLpr",
|
||||||
"cameraMqttConfig",
|
"cameraMqttConfig",
|
||||||
"cameraOnvif",
|
"cameraOnvif",
|
||||||
"cameraUi",
|
|
||||||
"cameraTimestampStyle",
|
"cameraTimestampStyle",
|
||||||
"cameraManagement",
|
"cameraManagement",
|
||||||
"masksAndZones",
|
"masksAndZones",
|
||||||
@ -292,9 +291,6 @@ const CameraMqttConfigSettingsPage = createSectionPage("mqtt", "camera", {
|
|||||||
const CameraOnvifSettingsPage = createSectionPage("onvif", "camera", {
|
const CameraOnvifSettingsPage = createSectionPage("onvif", "camera", {
|
||||||
showOverrideIndicator: false,
|
showOverrideIndicator: false,
|
||||||
});
|
});
|
||||||
const CameraUiSettingsPage = createSectionPage("ui", "camera", {
|
|
||||||
showOverrideIndicator: false,
|
|
||||||
});
|
|
||||||
const CameraTimestampStyleSettingsPage = createSectionPage(
|
const CameraTimestampStyleSettingsPage = createSectionPage(
|
||||||
"timestamp_style",
|
"timestamp_style",
|
||||||
"camera",
|
"camera",
|
||||||
@ -361,7 +357,6 @@ const settingsGroups = [
|
|||||||
{ key: "cameraLpr", component: CameraLprSettingsPage },
|
{ key: "cameraLpr", component: CameraLprSettingsPage },
|
||||||
{ key: "cameraOnvif", component: CameraOnvifSettingsPage },
|
{ key: "cameraOnvif", component: CameraOnvifSettingsPage },
|
||||||
{ key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage },
|
{ key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage },
|
||||||
{ key: "cameraUi", component: CameraUiSettingsPage },
|
|
||||||
{
|
{
|
||||||
key: "cameraTimestampStyle",
|
key: "cameraTimestampStyle",
|
||||||
component: CameraTimestampStyleSettingsPage,
|
component: CameraTimestampStyleSettingsPage,
|
||||||
@ -467,7 +462,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
|||||||
"cameraLpr",
|
"cameraLpr",
|
||||||
"cameraMqttConfig",
|
"cameraMqttConfig",
|
||||||
"cameraOnvif",
|
"cameraOnvif",
|
||||||
"cameraUi",
|
|
||||||
"cameraTimestampStyle",
|
"cameraTimestampStyle",
|
||||||
"masksAndZones",
|
"masksAndZones",
|
||||||
"motionTuner",
|
"motionTuner",
|
||||||
@ -495,7 +489,6 @@ const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
|||||||
lpr: "cameraLpr",
|
lpr: "cameraLpr",
|
||||||
mqtt: "cameraMqttConfig",
|
mqtt: "cameraMqttConfig",
|
||||||
onvif: "cameraOnvif",
|
onvif: "cameraOnvif",
|
||||||
ui: "cameraUi",
|
|
||||||
timestamp_style: "cameraTimestampStyle",
|
timestamp_style: "cameraTimestampStyle",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user